Skip to content

Commit

Permalink
add morphs support & unify model binding attributes (breakchange)
Browse files Browse the repository at this point in the history
  • Loading branch information
d8vjork committed Oct 17, 2023
1 parent fecf98a commit dbb325c
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 57 deletions.
88 changes: 88 additions & 0 deletions src/Attributes/BindModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

namespace OpenSoutheners\LaravelDto\Attributes;

use Attribute;
use Exception;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;

#[Attribute(Attribute::TARGET_PROPERTY)]
class BindModel
{
public function __construct(
public string|array|null $using = null,
public string|array $with = [],
public string|null $morphTypeKey = null
) {
//
}

public static function getDefaultMorphKeyFrom(string $key): string
{
return "{$key}_type";
}

/**
* L.
*/
public function getBindingAttribute(string $key, string $type): string|array|null
{
$usingAttribute = $this->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;
}
}
14 changes: 0 additions & 14 deletions src/Attributes/BindModelUsing.php

This file was deleted.

14 changes: 0 additions & 14 deletions src/Attributes/BindModelWith.php

This file was deleted.

64 changes: 40 additions & 24 deletions src/PropertiesMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
use BackedEnum;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Exception;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use OpenSoutheners\LaravelDto\Attributes\BindModelUsing;
use OpenSoutheners\LaravelDto\Attributes\BindModelWith;
use OpenSoutheners\LaravelDto\Attributes\BindModel;
use OpenSoutheners\LaravelDto\Attributes\NormaliseProperties;
use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue;
use ReflectionAttribute;
use ReflectionClass;
use stdClass;
use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor;
Expand Down Expand Up @@ -79,6 +81,8 @@ public function run(): static
}

$preferredType = reset($propertyTypes);
$propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes));
$propertyTypesModelClasses = array_filter($propertyTypesClasses, fn ($typeClass) => is_a($typeClass, Model::class, true));
$preferredTypeClass = $preferredType->getClassName();

/** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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<class-string<\Illuminate\Database\Eloquent\Model>> $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) : []
);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/Fixtures/UpdatePostWithDefaultData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 = ''
Expand Down
4 changes: 2 additions & 2 deletions tests/Fixtures/UpdatePostWithRouteBindingData.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down

0 comments on commit dbb325c

Please sign in to comment.