diff --git a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php index 29bb7070..cc008214 100644 --- a/src/DataPipes/FillRouteParameterPropertiesDataPipe.php +++ b/src/DataPipes/FillRouteParameterPropertiesDataPipe.php @@ -24,10 +24,7 @@ public function handle( } foreach ($class->properties as $dataProperty) { - /** @var FromRouteParameter|null $attribute */ - $attribute = $dataProperty->attributes->first( - fn (object $attribute) => $attribute instanceof FromRouteParameter - ); + $attribute = $dataProperty->attributes->getAttribute(FromRouteParameter::class); if ($attribute === null) { continue; diff --git a/src/Normalizers/Normalized/NormalizedModel.php b/src/Normalizers/Normalized/NormalizedModel.php index 0d5196f2..626fa16f 100644 --- a/src/Normalizers/Normalized/NormalizedModel.php +++ b/src/Normalizers/Normalized/NormalizedModel.php @@ -37,7 +37,7 @@ public function getProperty(string $name, DataProperty $dataProperty): mixed protected function fetchNewProperty(string $name, DataProperty $dataProperty): mixed { - if ($dataProperty->attributes->contains(fn (object $attribute) => $attribute::class === LoadRelation::class)) { + if ($dataProperty->attributes->hasAttribute(LoadRelation::class)) { if (method_exists($this->model, $name)) { $this->model->loadMissing($name); } @@ -58,25 +58,4 @@ protected function fetchNewProperty(string $name, DataProperty $dataProperty): m return $this->properties[$name] = UnknownProperty::create(); } - - protected function hasModelAttribute(string $name): bool - { - if (method_exists($this->model, 'hasAttribute')) { - return $this->model->hasAttribute($name); - } - - // TODO: remove this once we stop supporting Laravel 10 - if (! isset($this->attributesProperty)) { - $this->attributesProperty = new ReflectionProperty($this->model, 'attributes'); - } - - if (! isset($this->castsProperty)) { - $this->castsProperty = new ReflectionProperty($this->model, 'casts'); - } - - return array_key_exists($name, $this->attributesProperty->getValue($this->model)) || - array_key_exists($name, $this->castsProperty->getValue($this->model)) || - $this->model->hasGetMutator($name) || - $this->model->hasAttributeMutator($name); - } } diff --git a/src/Resolvers/NameMappersResolver.php b/src/Resolvers/NameMappersResolver.php index 5ca820d1..02758ef1 100644 --- a/src/Resolvers/NameMappersResolver.php +++ b/src/Resolvers/NameMappersResolver.php @@ -8,6 +8,7 @@ use Spatie\LaravelData\Attributes\MapOutputName; use Spatie\LaravelData\Mappers\NameMapper; use Spatie\LaravelData\Mappers\ProvidedNameMapper; +use Spatie\LaravelData\Support\AttributeCollection; class NameMappersResolver { @@ -21,7 +22,7 @@ public function __construct(protected array $ignoredMappers = []) } public function execute( - Collection $attributes + AttributeCollection $attributes ): array { return [ 'inputNameMapper' => $this->resolveInputNameMapper($attributes), @@ -30,11 +31,11 @@ public function execute( } protected function resolveInputNameMapper( - Collection $attributes + AttributeCollection $attributes ): ?NameMapper { /** @var MapInputName|MapName|null $mapper */ - $mapper = $attributes->first(fn (object $attribute) => $attribute instanceof MapInputName) - ?? $attributes->first(fn (object $attribute) => $attribute instanceof MapName); + $mapper = $attributes->getAttribute(MapInputName::class) + ?? $attributes->getAttribute(MapName::class); if ($mapper) { return $this->resolveMapper($mapper->input); @@ -44,11 +45,11 @@ protected function resolveInputNameMapper( } protected function resolveOutputNameMapper( - Collection $attributes + AttributeCollection $attributes ): ?NameMapper { /** @var MapOutputName|MapName|null $mapper */ - $mapper = $attributes->first(fn (object $attribute) => $attribute instanceof MapOutputName) - ?? $attributes->first(fn (object $attribute) => $attribute instanceof MapName); + $mapper = $attributes->getAttribute(MapOutputName::class) + ?? $attributes->getAttribute(MapName::class); if ($mapper) { return $this->resolveMapper($mapper->output); diff --git a/src/RuleInferrers/AttributesRuleInferrer.php b/src/RuleInferrers/AttributesRuleInferrer.php index 14045333..819a944d 100644 --- a/src/RuleInferrers/AttributesRuleInferrer.php +++ b/src/RuleInferrers/AttributesRuleInferrer.php @@ -24,7 +24,7 @@ public function handle( ): PropertyRules { $property ->attributes - ->filter(fn (object $attribute) => $attribute instanceof ValidationRule) + ->getAttributes(ValidationRule::class) ->each(function (ValidationRule $rule) use ($rules) { if ($rule instanceof Present && $rules->hasType(RequiringRule::class)) { $rules->removeType(RequiringRule::class); diff --git a/src/Support/AttributeCollection.php b/src/Support/AttributeCollection.php new file mode 100644 index 00000000..15c2d292 --- /dev/null +++ b/src/Support/AttributeCollection.php @@ -0,0 +1,79 @@ + $attribute->newInstance(), + array_filter($attributes, fn (ReflectionAttribute $attribute) => class_exists($attribute->getName())) + ) + ); + } + + public function add($item): static + { + unset($this->groups); + return parent::add($item); + } + + public function offsetSet($key, $value): void + { + unset($this->groups); + parent::offsetSet($key, $value); + } + + private function maybeProcessItemsIntoGroups(): void + { + if (!isset($this->groups)) { + foreach ($this->items as $item) { + $implements = class_implements($item); + $parents = class_parents($item); + foreach (array_merge([get_class($item)], $implements, $parents) as $parent) { + $this->groups[$parent][] = $item; + } + } + } + } + + /** + * @param class-string $attributeClass + */ + public function hasAttribute(string $attributeClass): bool + { + $this->maybeProcessItemsIntoGroups(); + return !empty($this->groups[$attributeClass]); + } + + /** + * @template T + * @param class-string $attributeClass + * + * @return Collection + */ + public function getAttributes(string $attributeClass): Collection + { + $this->maybeProcessItemsIntoGroups(); + return collect($this->groups[$attributeClass] ?? []); + } + + /** + * @template T + * @param class-string $attributeClass + * + * @return ?T + */ + public function getAttribute(string $attributeClass): ?object + { + $this->maybeProcessItemsIntoGroups(); + return current($this->groups[$attributeClass] ?? []) ?: null; + } +} diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index 003ebeba..1b61ac02 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -27,7 +27,7 @@ public function __construct( public readonly bool $validateable, public readonly bool $wrappable, public readonly bool $emptyData, - public readonly Collection $attributes, + public readonly AttributeCollection $attributes, public readonly array $dataIterablePropertyAnnotations, public DataStructureProperty $allowedRequestIncludes, public DataStructureProperty $allowedRequestExcludes, diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index 5075c03f..83b112a9 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -28,7 +28,7 @@ public function __construct( public readonly ?Transformer $transformer, public readonly ?string $inputMappedName, public readonly ?string $outputMappedName, - public readonly Collection $attributes, + public readonly AttributeCollection $attributes, ) { } } diff --git a/src/Support/Factories/DataClassFactory.php b/src/Support/Factories/DataClassFactory.php index 4aa89f98..ce1af417 100644 --- a/src/Support/Factories/DataClassFactory.php +++ b/src/Support/Factories/DataClassFactory.php @@ -21,6 +21,7 @@ use Spatie\LaravelData\Mappers\ProvidedNameMapper; use Spatie\LaravelData\Resolvers\NameMappersResolver; use Spatie\LaravelData\Support\Annotations\DataIterableAnnotationReader; +use Spatie\LaravelData\Support\AttributeCollection; use Spatie\LaravelData\Support\DataClass; use Spatie\LaravelData\Support\DataProperty; use Spatie\LaravelData\Support\LazyDataStructureProperty; @@ -105,22 +106,26 @@ public function build(ReflectionClass $reflectionClass): DataClass ); } - protected function resolveAttributes( - ReflectionClass $reflectionClass - ): Collection { - $attributes = collect($reflectionClass->getAttributes()) - ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) - ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + private function resolveRecursiveAttributes(ReflectionClass $reflectionClass): array + { + + $attributes = $reflectionClass->getAttributes(); $parent = $reflectionClass->getParentClass(); if ($parent !== false) { - $attributes = $attributes->merge(static::resolveAttributes($parent)); + $attributes = array_merge($attributes, static::resolveRecursiveAttributes($parent)); } return $attributes; } + protected function resolveAttributes( + ReflectionClass $reflectionClass + ): AttributeCollection { + return AttributeCollection::makeFromReflectionAttributes(static::resolveRecursiveAttributes($reflectionClass)); + } + protected function resolveMethods( ReflectionClass $reflectionClass, ): Collection { diff --git a/src/Support/Factories/DataPropertyFactory.php b/src/Support/Factories/DataPropertyFactory.php index 4b71c86b..5c1b3959 100644 --- a/src/Support/Factories/DataPropertyFactory.php +++ b/src/Support/Factories/DataPropertyFactory.php @@ -16,6 +16,7 @@ use Spatie\LaravelData\Optional; use Spatie\LaravelData\Resolvers\NameMappersResolver; use Spatie\LaravelData\Support\Annotations\DataIterableAnnotation; +use Spatie\LaravelData\Support\AttributeCollection; use Spatie\LaravelData\Support\DataProperty; class DataPropertyFactory @@ -35,9 +36,7 @@ public function build( ?DataIterableAnnotation $classDefinedDataIterableAnnotation = null, ?AutoLazy $classAutoLazy = null, ): DataProperty { - $attributes = collect($reflectionProperty->getAttributes()) - ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) - ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); + $attributes = AttributeCollection::makeFromReflectionAttributes($reflectionProperty->getAttributes()); $type = $this->typeFactory->buildProperty( $reflectionProperty->getType(), @@ -61,17 +60,11 @@ public function build( default => null, }; - $computed = $attributes->contains( - fn (object $attribute) => $attribute instanceof Computed - ); + $computed = $attributes->hasAttribute(Computed::class); - $hidden = $attributes->contains( - fn (object $attribute) => $attribute instanceof Hidden - ); + $hidden = $attributes->hasAttribute(Hidden::class); - $validate = ! $attributes->contains( - fn (object $attribute) => $attribute instanceof WithoutValidation - ) && ! $computed; + $validate = ! $attributes->hasAttribute(WithoutValidation::class) && ! $computed; if (! $reflectionProperty->isPromoted()) { $hasDefaultValue = $reflectionProperty->hasDefaultValue(); @@ -103,8 +96,8 @@ className: $reflectionProperty->class, autoLazy: $autoLazy, hasDefaultValue: $hasDefaultValue, defaultValue: $defaultValue, - cast: $attributes->first(fn (object $attribute) => $attribute instanceof GetsCast)?->get(), - transformer: $attributes->first(fn (object $attribute) => $attribute instanceof WithTransformer || $attribute instanceof WithCastAndTransformer)?->get(), + cast: $attributes->getAttribute(GetsCast::class)?->get(), + transformer: ($attributes->getAttribute(WithTransformer::class) ?? $attributes->getAttribute(WithCastAndTransformer::class))?->get(), inputMappedName: $inputMappedName, outputMappedName: $outputMappedName, attributes: $attributes, diff --git a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php index 66cdff01..5bbc73b0 100644 --- a/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php +++ b/src/Support/TypeScriptTransformer/DataTypeScriptTransformer.php @@ -53,9 +53,7 @@ protected function transformProperties( ): string { $dataClass = app(DataConfig::class)->getDataClass($class->getName()); - $isOptional = $dataClass->attributes->contains( - fn (object $attribute) => $attribute instanceof TypeScriptOptional - ); + $isOptional = $dataClass->attributes->hasAttribute(TypeScriptOptional::class); return array_reduce( $this->resolveProperties($class), @@ -76,9 +74,7 @@ function (string $carry, ReflectionProperty $property) use ($isOptional, $dataCl } $isOptional = $isOptional - || $dataProperty->attributes->contains( - fn (object $attribute) => $attribute instanceof TypeScriptOptional - ) + || $dataProperty->attributes->hasAttribute(TypeScriptOptional::class) || ($dataProperty->type->lazyType && $dataProperty->type->lazyType !== ClosureLazy::class) || $dataProperty->type->isOptional || ($dataProperty->type->isNullable && $this->config->shouldConsiderNullAsOptional()); diff --git a/tests/Normalizers/ModelNormalizerTest.php b/tests/Normalizers/ModelNormalizerTest.php index 747e7434..918e41bc 100644 --- a/tests/Normalizers/ModelNormalizerTest.php +++ b/tests/Normalizers/ModelNormalizerTest.php @@ -72,7 +72,7 @@ ->old_accessor->toEqual($data->old_accessor); }); -it('it will only call model accessors when required', function () { +it('will only call model accessors when required', function () { $dataClass = new class () extends Data { public string $accessor; diff --git a/tests/Resolvers/NameMappersResolverTest.php b/tests/Resolvers/NameMappersResolverTest.php index 31a260fd..3c3ea90c 100644 --- a/tests/Resolvers/NameMappersResolverTest.php +++ b/tests/Resolvers/NameMappersResolverTest.php @@ -9,11 +9,11 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Mappers\StudlyCaseMapper; use Spatie\LaravelData\Resolvers\NameMappersResolver; +use Spatie\LaravelData\Support\AttributeCollection; -function getAttributes(object $class): Collection +function getAttributes(object $class): AttributeCollection { - return collect((new ReflectionProperty($class, 'property'))->getAttributes()) - ->map(fn (ReflectionAttribute $attribute) => $attribute->newInstance()); + return AttributeCollection::makeFromReflectionAttributes((new ReflectionProperty($class, 'property'))->getAttributes()); } beforeEach(function () { diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 08dcd0b3..4c3e09c7 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -291,7 +291,7 @@ public static function rules(): array ]); })->skip('Add a new ruleinferrer to rule them all and make these cases better'); -it('it will take care of mapping', function () { +it('will take care of mapping', function () { DataValidationAsserter::for(new class () extends Data { #[MapInputName('some_property')] public string $property;