diff --git a/src/Attributes/BindModel.php b/src/Attributes/BindModel.php new file mode 100644 index 0000000..253b45d --- /dev/null +++ b/src/Attributes/BindModel.php @@ -0,0 +1,88 @@ +using; + + if (is_array($usingAttribute)) { + $typeModel = array_flip(Relation::morphMap())[$type]; + + $usingAttribute = $this->using[$typeModel] ?? null; + } + + /** @var \Illuminate\Http\Request|null $request */ + $request = app(Request::class); + + if (! $usingAttribute && $request && $request->route($key)) { + return $request->route()->bindingFieldFor($key); + } + + return $usingAttribute; + } + + public function getRelationshipsFor(string $type): array + { + $withRelations = (array) $this->with; + + $withRelations = $withRelations[$type] ?? $withRelations; + + return (array) $withRelations; + } + + public function getMorphPropertyTypeKey(string $fromPropertyKey): string + { + return $this->morphTypeKey ?? static::getDefaultMorphKeyFrom($fromPropertyKey); + } + + public function getMorphModel(string $fromPropertyKey, array $properties, array $propertyTypeClasses): string + { + $morphTypePropertyKey = $this->getMorphPropertyTypeKey($fromPropertyKey); + + $type = $properties[$morphTypePropertyKey] ?? null; + + if (! $type) { + throw new Exception('Morph type must be specified to be able to bind a model from a morph.'); + } + + $morphMap = Relation::morphMap(); + $modelModelClass = $morphMap[$type] ?? null; + + if (! $modelModelClass && count($propertyTypeClasses) > 0) { + $modelModelClass = array_filter($propertyTypeClasses, fn (string $class) => (new $class)->getMorphClass() === $type); + + $modelModelClass = reset($modelModelClass); + } + + if (! $modelModelClass) { + throw new Exception('Morph type not found on relation map or within types.'); + } + + return $modelModelClass; + } +} diff --git a/src/Attributes/BindModelUsing.php b/src/Attributes/BindModelUsing.php deleted file mode 100644 index 87b0dbc..0000000 --- a/src/Attributes/BindModelUsing.php +++ /dev/null @@ -1,14 +0,0 @@ - $type->getClassName(), $propertyTypes)); + $propertyTypesModelClasses = array_filter($propertyTypesClasses, fn ($typeClass) => is_a($typeClass, Model::class, true)); $preferredTypeClass = $preferredType->getClassName(); /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ @@ -113,8 +117,8 @@ public function run(): static } $this->data[$key] = match (true) { - $preferredType->isCollection() || $preferredTypeClass === Collection::class || $preferredTypeClass === EloquentCollection::class => $this->mapIntoCollection($propertyTypes, $key, $value), - is_subclass_of($preferredTypeClass, Model::class) => $this->mapIntoModel($preferredTypeClass, $key, $value), + $preferredType->isCollection() || $preferredTypeClass === Collection::class || $preferredTypeClass === EloquentCollection::class => $this->mapIntoCollection($propertyTypes, $key, $value, $propertyAttributes), + is_subclass_of($preferredTypeClass, Model::class) => $this->mapIntoModel(count($propertyTypesModelClasses) === 1 ? $preferredTypeClass : $propertyTypesClasses, $key, $value, $propertyAttributes), is_subclass_of($preferredTypeClass, BackedEnum::class) => $value instanceof $preferredTypeClass ? $value : $preferredTypeClass::tryFrom($value), is_subclass_of($preferredTypeClass, CarbonInterface::class) || $preferredTypeClass === CarbonInterface::class => $this->mapIntoCarbonDate($preferredTypeClass, $value), $preferredTypeClass === stdClass::class && is_array($value) => (object) $value, @@ -141,7 +145,7 @@ public function get(): array * * @param class-string<\Illuminate\Database\Eloquent\Model> $model */ - protected function getModelInstance(string $model, mixed $id, string $usingAttribute = null, array $with = []) + protected function getModelInstance(string|array $model, mixed $id, string $usingAttribute = null, array $with = []) { if (is_a($id, $model)) { return empty($with) ? $id : $id->loadMissing($with); @@ -192,31 +196,32 @@ protected function normalisePropertyKey(string $key): ?string /** * Map data value into model instance. * - * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + * @param class-string<\Illuminate\Database\Eloquent\Model>|array> $modelClass + * @param \Illuminate\Support\Collection<\ReflectionAttribute> $attributes */ - protected function mapIntoModel(string $modelClass, string $propertyKey, mixed $value) + protected function mapIntoModel(string|array $modelClass, string $propertyKey, mixed $value, Collection $attributes) { - $bindModelWithAttribute = $this->reflector->getProperty($propertyKey)->getAttributes(BindModelWith::class); - /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModelWith>|null $bindModelWithAttribute */ - $bindModelWithAttribute = reset($bindModelWithAttribute); + /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>|null $bindModelAttribute */ + $bindModelAttribute = $attributes + ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === BindModel::class) + ->first(); - $bindModelUsingAttribute = $this->reflector->getProperty($propertyKey)->getAttributes(BindModelUsing::class); - /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModelUsing>|null $bindModelUsingAttribute */ - $bindModelUsingAttribute = reset($bindModelUsingAttribute); + /** @var \OpenSoutheners\LaravelDto\Attributes\BindModel|null $bindModelAttribute */ + $bindModelAttribute = $bindModelAttribute ? $bindModelAttribute->newInstance() : null; - $bindModelUsingAttribute = $bindModelUsingAttribute ? $bindModelUsingAttribute->newInstance()->attribute : null; - - if (! $bindModelUsingAttribute && app(Request::class)->route($propertyKey)) { - $bindModelUsingAttribute = app(Request::class)->route()->bindingFieldFor($propertyKey) ?? (new $modelClass)->getRouteKeyName(); + if (! $bindModelAttribute && is_array($modelClass)) { + $bindModelAttribute = new BindModel(morphKey: BindModel::getDefaultMorphKeyFrom($propertyKey)); } + $model = $bindModelAttribute && is_array($modelClass) + ? $bindModelAttribute->getMorphModel($propertyKey, $this->properties, $modelClass) + : $modelClass; + return $this->getModelInstance( - $modelClass, + $model, $value, - $bindModelUsingAttribute, - $bindModelWithAttribute - ? (array) $bindModelWithAttribute->newInstance()->relationships - : [] + $bindModelAttribute ? $bindModelAttribute->getBindingAttribute($propertyKey, $model) : null, + $bindModelAttribute ? $bindModelAttribute->getRelationshipsFor($model) : [] ); } @@ -237,8 +242,9 @@ public function mapIntoCarbonDate($carbonClass, mixed $value): ?CarbonInterface * * @param array<\Symfony\Component\PropertyInfo\Type> $propertyTypes * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection|array|string $value + * @param \Illuminate\Support\Collection<\ReflectionAttribute> $attributes */ - protected function mapIntoCollection(array $propertyTypes, string $propertyKey, mixed $value) + protected function mapIntoCollection(array $propertyTypes, string $propertyKey, mixed $value, Collection $attributes) { if ($value instanceof Collection) { return $value instanceof EloquentCollection ? $value->toBase() : $value; @@ -269,7 +275,17 @@ protected function mapIntoCollection(array $propertyTypes, string $propertyKey, if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { - $collection = $this->mapIntoModel($preferredCollectionTypeClass, $propertyKey, $collection); + $collectionTypeModelClasses = array_filter( + array_map(fn (Type $type) => $type->getClassName(), $collectionTypes), + fn ($typeClass) => is_a($typeClass, Model::class, true) + ); + + $collection = $this->mapIntoModel( + count($collectionTypeModelClasses) === 1 ? $preferredCollectionTypeClass : $collectionTypeModelClasses, + $propertyKey, + $collection, + $attributes + ); } else { $collection = $collection->map( fn ($item) => is_array($item) diff --git a/tests/Fixtures/UpdatePostWithDefaultData.php b/tests/Fixtures/UpdatePostWithDefaultData.php index 3a7d01c..d086a40 100644 --- a/tests/Fixtures/UpdatePostWithDefaultData.php +++ b/tests/Fixtures/UpdatePostWithDefaultData.php @@ -3,7 +3,7 @@ namespace OpenSoutheners\LaravelDto\Tests\Fixtures; use Illuminate\Contracts\Auth\Authenticatable; -use OpenSoutheners\LaravelDto\Attributes\BindModelUsing; +use OpenSoutheners\LaravelDto\Attributes\BindModel; use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; use OpenSoutheners\LaravelDto\DataTransferObject; @@ -13,12 +13,12 @@ class UpdatePostWithDefaultData extends DataTransferObject * @param string[] $tags */ public function __construct( - #[BindModelUsing('slug')] + #[BindModel('slug')] #[WithDefaultValue('hello-world')] public Post $post, #[WithDefaultValue(Authenticatable::class)] public User $author, - public ?Post $parent = null, + public Post|Tag|null $parent = null, public array|string|null $country = null, public array $tags = [], public string $description = '' diff --git a/tests/Fixtures/UpdatePostWithRouteBindingData.php b/tests/Fixtures/UpdatePostWithRouteBindingData.php index b404358..de27dca 100644 --- a/tests/Fixtures/UpdatePostWithRouteBindingData.php +++ b/tests/Fixtures/UpdatePostWithRouteBindingData.php @@ -6,7 +6,7 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\AsType; -use OpenSoutheners\LaravelDto\Attributes\BindModelWith; +use OpenSoutheners\LaravelDto\Attributes\BindModel; use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; use OpenSoutheners\LaravelDto\DataTransferObject; use stdClass; @@ -18,7 +18,7 @@ class UpdatePostWithRouteBindingData extends DataTransferObject implements Valid * @param \Illuminate\Support\Collection<\OpenSoutheners\LaravelDto\Tests\Fixtures\Tag>|null $tags */ public function __construct( - #[BindModelWith('tags')] + #[BindModel(with: 'tags')] public Post $post, public ?string $title = null, public ?stdClass $content = null,