From 8860f71c202afc8549a1b1530f55e23a9bff9d45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Mon, 19 May 2025 20:58:35 +0200 Subject: [PATCH 01/13] wip --- composer.json | 5 +- src/Attributes/Inject.php | 14 + src/Attributes/ModelWith.php | 14 + .../{BindModel.php => ResolveModel.php} | 27 +- src/Attributes/Validate.php | 11 + src/DynamicMapper.php | 79 ++++ src/ObjectMapper.php | 182 +++++++++ src/PropertiesMapper.php | 376 ------------------ .../BackedEnumPropertyMapper.php | 32 ++ src/PropertyMappers/CarbonPropertyMapper.php | 38 ++ .../CollectionPropertyMapper.php | 92 +++++ .../GenericObjectPropertyMapper.php | 36 ++ src/PropertyMappers/ModelPropertyMapper.php | 150 +++++++ src/PropertyMappers/ObjectPropertyMapper.php | 48 +++ src/PropertyMappers/PropertyMapper.php | 22 + ...nsferObject.php => SerializableObject.php} | 125 ++---- src/ServiceProvider.php | 31 +- src/TypeGenerator.php | 13 +- src/functions.php | 7 + 19 files changed, 786 insertions(+), 516 deletions(-) create mode 100644 src/Attributes/Inject.php create mode 100644 src/Attributes/ModelWith.php rename src/Attributes/{BindModel.php => ResolveModel.php} (81%) create mode 100644 src/Attributes/Validate.php create mode 100644 src/DynamicMapper.php create mode 100644 src/ObjectMapper.php delete mode 100644 src/PropertiesMapper.php create mode 100644 src/PropertyMappers/BackedEnumPropertyMapper.php create mode 100644 src/PropertyMappers/CarbonPropertyMapper.php create mode 100644 src/PropertyMappers/CollectionPropertyMapper.php create mode 100644 src/PropertyMappers/GenericObjectPropertyMapper.php create mode 100644 src/PropertyMappers/ModelPropertyMapper.php create mode 100644 src/PropertyMappers/ObjectPropertyMapper.php create mode 100644 src/PropertyMappers/PropertyMapper.php rename src/{DataTransferObject.php => SerializableObject.php} (74%) create mode 100644 src/functions.php diff --git a/composer.json b/composer.json index d6f6281..0f36a98 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,10 @@ "autoload": { "psr-4": { "OpenSoutheners\\LaravelDto\\": "src" - } + }, + "files": [ + "src/functions.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/src/Attributes/Inject.php b/src/Attributes/Inject.php new file mode 100644 index 0000000..d876234 --- /dev/null +++ b/src/Attributes/Inject.php @@ -0,0 +1,14 @@ +using; + $usingAttribute = $this->keyFromRouteParam; if (is_array($usingAttribute)) { $typeModel = array_flip(Relation::morphMap())[$type]; - $usingAttribute = $this->using[$typeModel] ?? null; + $usingAttribute = $this->keyFromRouteParam[$typeModel] ?? null; } /** @var \Illuminate\Http\Request|null $request */ @@ -61,19 +60,10 @@ protected function resolveBinding(string $model, mixed $value, mixed $field = nu ->with($with); } - public function getRelationshipsFor(string $type): array - { - $withRelations = (array) $this->with; - - $withRelations = $withRelations[$type] ?? $withRelations; - - return (array) $withRelations; - } - public function getMorphPropertyTypeKey(string $fromPropertyKey): string { - if ($this->morphTypeKey) { - return Str::snake($this->morphTypeKey); + if ($this->morphTypeFrom) { + return Str::snake($this->morphTypeFrom); } return static::getDefaultMorphKeyFrom($fromPropertyKey); @@ -98,9 +88,6 @@ public function getMorphModel(string $fromPropertyKey, array $properties, array ); if (count($modelModelClass) === 0 && count($propertyTypeClasses) > 0) { - var_dump($propertyTypeClasses); - var_dump($morphMap); - var_dump($types); $modelModelClass = array_filter( $propertyTypeClasses, fn (string $class) => in_array((new $class())->getMorphClass(), $types) diff --git a/src/Attributes/Validate.php b/src/Attributes/Validate.php new file mode 100644 index 0000000..21c91db --- /dev/null +++ b/src/Attributes/Validate.php @@ -0,0 +1,11 @@ +dataClass = get_class($input); + } + + $this->data = $this->takeDataFrom($input); + } + + protected function extractProperties(object $input): array + { + $reflector = new ReflectionClass($input); + $extraction = []; + + foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $extraction[$property->getName()] = $property->getValue($input); + } + + return $extraction; + } + + protected function takeDataFrom(mixed $input): array + { + return match (true) { + $input instanceof Request => array_merge( + is_object($input->route()) ? $input->route()->parameters() : [], + $input instanceof FormRequest ? $input->validated() : $input->all() + ), + is_object($input) => $this->extractProperties($input), + default => (array) $input, + }; + } + + /** + * @template T + * @param class-string + * @return T + */ + public function to(?string $output = null) + { + if ($output && is_a($output, Model::class, true)) { + /** @var Model $model */ + $model = new $output; + + foreach ($this->data as $key => $value) { + if ($model->isRelation($key) && $model->$key() instanceof BelongsTo) { + $model->$key()->associate($value); + } + + $model->fill([$key => $value]); + } + + return $model; + } + + $output ??= $this->dataClass; + + $propertiesMapper = new ObjectMapper($this->data, $output); + + return new $output(...$propertiesMapper->run()); + } +} diff --git a/src/ObjectMapper.php b/src/ObjectMapper.php new file mode 100644 index 0000000..e409847 --- /dev/null +++ b/src/ObjectMapper.php @@ -0,0 +1,182 @@ + + */ + protected static array $mappers = []; + + /** + * Get instance of property info extractor. + */ + public static function registerMapper(array|PropertyMapper $mappers) + { + static::$mappers = array_merge(static::$mappers, $mappers); + } + + /** + * Get instance of property info extractor. + * + * @return array> + */ + public static function getPropertiesInfoFrom(string $class, ?string $property = null): array + { + $phpStanExtractor = new PhpStanExtractor(); + $reflectionExtractor = new ReflectionExtractor(); + + $extractor = new PropertyInfoExtractor( + [$reflectionExtractor], + [$phpStanExtractor, $reflectionExtractor], + ); + + if ($property) { + return [$property => $extractor->getTypes($class, $property) ?? []]; + } + + $propertiesInfo = []; + + foreach ($extractor->getProperties($class) as $key) { + $propertiesInfo[$key] = $extractor->getTypes($class, $key) ?? []; + } + + return $propertiesInfo; + } + + public function __construct( + protected array $properties, + protected string $dataClass, + protected array $data = [] + ) { + $this->reflector = new ReflectionClass($this->dataClass); + } + + /** + * Run properties mapper through all sent class properties. + */ + public function run(): array + { + $propertiesInfo = static::getPropertiesInfoFrom($this->dataClass); + + $propertiesData = array_combine( + array_map(fn ($key) => $this->normalisePropertyKey($key), array_keys($this->properties)), + array_values($this->properties) + ); + + foreach ($propertiesInfo as $key => $propertyTypes) { + $value = $propertiesData[$key] ?? null; + + if (count($propertyTypes) === 0) { + $this->data[$key] = $value; + + continue; + } + + $preferredType = reset($propertyTypes); + $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); + // TODO: for models + $propertyTypesModelClasses = array_filter($propertyTypesClasses, fn ($typeClass) => is_a($typeClass, Model::class, true)); + $preferredTypeClass = $preferredType->getClassName(); + + /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ + $propertyAttributes = Collection::make( + $this->reflector->getProperty($key)->getAttributes() + ); + + $propertyAttributesDefaultValue = $propertyAttributes->filter( + fn (ReflectionAttribute $attribute) => $attribute->getName() === WithDefaultValue::class + )->first(); + + $defaultValue = null; + + if (! $value && $propertyAttributesDefaultValue) { + $defaultValue = $propertyAttributesDefaultValue->newInstance()->value; + } + + $injectAttribute = $propertyAttributes->filter( + fn (ReflectionAttribute $attribute) => $attribute->getName() === Inject::class + )->first(); + + if ($injectAttribute) { + $this->data[$key] = app($injectAttribute->newInstance()->value); + + continue; + } + + $value ??= $defaultValue; + + if (is_null($value)) { + continue; + } + + if ( + $preferredTypeClass + && ! is_array($value) + && ! $preferredType->isCollection() + && $preferredTypeClass !== Collection::class + && ! is_a($preferredTypeClass, Model::class, true) + && (is_a($value, $preferredTypeClass, true) + || (is_object($value) && in_array(get_class($value), $propertyTypesClasses))) + ) { + $this->data[$key] = $value; + + continue; + } + + foreach (static::$mappers as $mapper) { + if ($mapper->assert($preferredType, $value)) { + $this->data[$key] = $mapper->resolve($propertyTypes, $key, $value, $propertyAttributes, $this->properties); + + break; + } + + $this->data[$key] = $value; + } + } + + return $this->data; + } + + /** + * Normalise property key using camel case or original. + */ + protected function normalisePropertyKey(string $key): ?string + { + $normaliseProperty = count($this->reflector->getAttributes(NormaliseProperties::class)) > 0 + ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); + + if (! $normaliseProperty) { + return $key; + } + + if (Str::endsWith($key, '_id')) { + $key = Str::replaceLast('_id', '', $key); + } + + $camelKey = Str::camel($key); + + return match (true) { + property_exists($this->dataClass, $key) => $key, + property_exists($this->dataClass, $camelKey) => $camelKey, + default => null + }; + } +} diff --git a/src/PropertiesMapper.php b/src/PropertiesMapper.php deleted file mode 100644 index aba71ea..0000000 --- a/src/PropertiesMapper.php +++ /dev/null @@ -1,376 +0,0 @@ -reflector = new ReflectionClass($this->dataClass); - } - - /** - * Run properties mapper through all sent class properties. - */ - public function run(): static - { - $propertyInfoExtractor = static::propertyInfoExtractor(); - - $propertiesData = array_combine( - array_map(fn ($key) => $this->normalisePropertyKey($key), array_keys($this->properties)), - array_values($this->properties) - ); - - foreach ($propertyInfoExtractor->getProperties($this->dataClass) as $key) { - $value = $propertiesData[$key] ?? null; - - /** @var array<\Symfony\Component\PropertyInfo\Type> $propertyTypes */ - $propertyTypes = $propertyInfoExtractor->getTypes($this->dataClass, $key) ?? []; - - if (count($propertyTypes) === 0) { - $this->data[$key] = $value; - - continue; - } - - $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 */ - $propertyAttributes = Collection::make( - $this->reflector->getProperty($key)->getAttributes() - ); - - $propertyAttributesDefaultValue = $propertyAttributes->filter( - fn (ReflectionAttribute $attribute) => $attribute->getName() === WithDefaultValue::class - )->first(); - - $defaultValue = null; - - if (! $value && $propertyAttributesDefaultValue) { - $defaultValue = $propertyAttributesDefaultValue->newInstance()->value; - } - - if ( - ! $value - && ($preferredTypeClass === Authenticatable::class || $defaultValue === Authenticatable::class) - && app('auth')->check() - ) { - $this->data[$key] = app('auth')->user(); - - continue; - } - - $value ??= $defaultValue; - - if (is_null($value)) { - continue; - } - - if ( - $preferredTypeClass - && ! is_array($value) - && ! $preferredType->isCollection() - && $preferredTypeClass !== Collection::class - && ! is_a($preferredTypeClass, Model::class, true) - && (is_a($value, $preferredTypeClass, true) - || (is_object($value) && in_array(get_class($value), $propertyTypesClasses))) - ) { - $this->data[$key] = $value; - - continue; - } - - $this->data[$key] = match (true) { - $preferredType->isCollection() || $preferredTypeClass === Collection::class || $preferredTypeClass === EloquentCollection::class => $this->mapIntoCollection($propertyTypes, $key, $value, $propertyAttributes), - $preferredTypeClass === Model::class || is_subclass_of($preferredTypeClass, Model::class) => $this->mapIntoModel(count($propertyTypesModelClasses) === 1 ? $preferredTypeClass : $propertyTypesClasses, $key, $value, $propertyAttributes), - is_subclass_of($preferredTypeClass, BackedEnum::class) => $preferredTypeClass::tryFrom($value) ?? (count($propertyTypes) > 1 ? $value : null), - is_subclass_of($preferredTypeClass, CarbonInterface::class) || $preferredTypeClass === CarbonInterface::class => $this->mapIntoCarbonDate($preferredTypeClass, $value), - $preferredTypeClass === stdClass::class && is_array($value) => (object) $value, - $preferredTypeClass === stdClass::class && Str::isJson($value) => json_decode($value), - $preferredTypeClass && class_exists($preferredTypeClass) && (new ReflectionClass($preferredTypeClass))->isInstantiable() && is_array($value) && is_string(array_key_first($value)) => new $preferredTypeClass(...$value), - $preferredTypeClass && class_exists($preferredTypeClass) && (new ReflectionClass($preferredTypeClass))->isInstantiable() && Str::isJson($value) => new $preferredTypeClass(...json_decode($value, true)), - default => $value, - }; - } - - return $this; - } - - /** - * Get data array from mapped typed properties. - */ - public function get(): array - { - return $this->data; - } - - /** - * Get model instance(s) for model class and given IDs. - * - * @param class-string<\Illuminate\Database\Eloquent\Model> $model - * @param string|int|array|\Illuminate\Database\Eloquent\Model $id - * @param string|\Illuminate\Database\Eloquent\Model $usingAttribute - */ - protected function getModelInstance(string $model, mixed $id, mixed $usingAttribute, array $with) - { - if (is_a($usingAttribute, $model)) { - return $usingAttribute; - } - - if (is_a($id, $model)) { - return empty($with) ? $id : $id->loadMissing($with); - } - - $baseQuery = $model::query()->when( - $usingAttribute, - fn (Builder $query) => is_iterable($id) ? $query->whereIn($usingAttribute, $id) : $query->where($usingAttribute, $id), - fn (Builder $query) => $query->whereKey($id) - ); - - if (count($with) > 0) { - $baseQuery->with($with); - } - - if (is_iterable($id)) { - return $baseQuery->get(); - } - - return $baseQuery->first(); - } - - /** - * Normalise property key using camel case or original. - */ - protected function normalisePropertyKey(string $key): ?string - { - $normaliseProperty = count($this->reflector->getAttributes(NormaliseProperties::class)) > 0 - ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); - - if (! $normaliseProperty) { - return $key; - } - - if (Str::endsWith($key, '_id')) { - $key = Str::replaceLast('_id', '', $key); - } - - $camelKey = Str::camel($key); - - return match (true) { - property_exists($this->dataClass, $key) => $key, - property_exists($this->dataClass, $camelKey) => $camelKey, - default => null - }; - } - - /** - * Map data value into model instance. - * - * @param class-string<\Illuminate\Database\Eloquent\Model>|array> $modelClass - * @param \Illuminate\Support\Collection<\ReflectionAttribute> $attributes - */ - protected function mapIntoModel(string|array $modelClass, string $propertyKey, mixed $value, Collection $attributes) - { - /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>|null $bindModelAttribute */ - $bindModelAttribute = $attributes - ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === BindModel::class) - ->first(); - - /** @var \OpenSoutheners\LaravelDto\Attributes\BindModel|null $bindModelAttribute */ - $bindModelAttribute = $bindModelAttribute - ? $bindModelAttribute->newInstance() - : new BindModel(morphTypeKey: BindModel::getDefaultMorphKeyFrom($propertyKey)); - - $modelType = $modelClass; - $valueClass = null; - - if (is_object($value) && ! $value instanceof Collection) { - $valueClass = get_class($value); - $modelType = is_array($modelClass) ? ($modelClass[$valueClass] ?? null) : $valueClass; - } - - if ( - (! is_array($modelType) && $modelType === Model::class) - || ($bindModelAttribute && is_array($modelClass)) - ) { - $modelType = $bindModelAttribute->getMorphModel( - $propertyKey, - $this->properties, - $modelClass === Model::class ? [] : (array) $modelClass - ); - } - - if (! is_countable($modelType) || count($modelType) === 1) { - return $this->resolveIntoModelInstance( - $value, - ! is_countable($modelType) ? $modelType : $modelType[0], - $propertyKey, - $bindModelAttribute - ); - } - - return Collection::make(array_map( - function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { - if (!is_null($valueB)) { - $lastNonValue = $valueB; - } - - return [$valueA, $valueB ?? $lastNonValue]; - }, - $value instanceof Collection ? $value->all() : (array) $value, - (array) $modelType - ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => - $this->resolveIntoModelInstance($keys, $model, $propertyKey, $bindModelAttribute) - ); - } - - /** - * Resolve model class strings and keys into instances. - */ - protected function resolveIntoModelInstance(mixed $keys, string $modelClass, string $propertyKey, ?BindModel $bindingAttribute = null): mixed - { - $usingAttribute = null; - $with = []; - - if ($bindingAttribute) { - $with = $bindingAttribute->getRelationshipsFor($modelClass); - $usingAttribute = $bindingAttribute->getBindingAttribute($propertyKey, $modelClass, $with); - } - - return $this->getModelInstance($modelClass, $keys, $usingAttribute, $with); - } - - /** - * Map data value into Carbon date/datetime instance. - */ - public function mapIntoCarbonDate($carbonClass, mixed $value): ?CarbonInterface - { - if ($carbonClass === CarbonImmutable::class) { - return CarbonImmutable::make($value); - } - - return Carbon::make($value); - } - - /** - * Map data value into collection of items with subtypes. - * - * @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, Collection $attributes) - { - if ($value instanceof Collection) { - return $value instanceof EloquentCollection ? $value->toBase() : $value; - } - - $propertyType = reset($propertyTypes); - - if ( - count(array_filter($propertyTypes, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 - && ! str_contains($value, ',') - ) { - return $value; - } - - if (is_json_structure($value)) { - $collection = Collection::make(json_decode($value, true)); - } else { - $collection = Collection::make( - is_array($value) - ? $value - : explode(',', $value) - ); - } - - $collectionTypes = $propertyType->getCollectionValueTypes(); - - $preferredCollectionType = reset($collectionTypes); - $preferredCollectionTypeClass = $preferredCollectionType ? $preferredCollectionType->getClassName() : null; - - $collection = $collection->map(fn ($value) => is_string($value) ? trim($value) : $value) - ->filter(fn($item) => !blank($item)); - - if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { - if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { - $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 - ); - } elseif (is_subclass_of($preferredCollectionTypeClass, DataTransferObject::class)) { - $collection = $collection->map( - fn ($item) => $preferredCollectionTypeClass::fromArray($item) - ); - } else { - $collection = $collection->map( - fn ($item) => is_array($item) - ? new $preferredCollectionTypeClass(...$item) - : new $preferredCollectionTypeClass($item) - ); - } - } - - if ($propertyType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { - $collection = $collection->all(); - } - - return $collection; - } -} diff --git a/src/PropertyMappers/BackedEnumPropertyMapper.php b/src/PropertyMappers/BackedEnumPropertyMapper.php new file mode 100644 index 0000000..07e8088 --- /dev/null +++ b/src/PropertyMappers/BackedEnumPropertyMapper.php @@ -0,0 +1,32 @@ +getClassName(), BackedEnum::class); + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + $preferredType = reset($types); + $preferredTypeClass = $preferredType->getClassName(); + + return $preferredTypeClass::tryFrom($value) ?? (count($types) > 1 ? $value : null); + } +} diff --git a/src/PropertyMappers/CarbonPropertyMapper.php b/src/PropertyMappers/CarbonPropertyMapper.php new file mode 100644 index 0000000..171478c --- /dev/null +++ b/src/PropertyMappers/CarbonPropertyMapper.php @@ -0,0 +1,38 @@ +getClassName() === CarbonInterface::class + || is_subclass_of($preferredType->getClassName(), CarbonInterface::class); + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + $preferredType = reset($types); + + if ($preferredType === CarbonImmutable::class) { + return CarbonImmutable::make($value); + } + + return Carbon::make($value); + } +} diff --git a/src/PropertyMappers/CollectionPropertyMapper.php b/src/PropertyMappers/CollectionPropertyMapper.php new file mode 100644 index 0000000..7aa57a5 --- /dev/null +++ b/src/PropertyMappers/CollectionPropertyMapper.php @@ -0,0 +1,92 @@ +isCollection() + || $preferredType->getClassName() === Collection::class + || $preferredType->getClassName() === EloquentCollection::class; + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + if ($value instanceof Collection) { + return $value instanceof EloquentCollection ? $value->toBase() : $value; + } + + $propertyType = reset($types); + + if ( + count(array_filter($types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 + && ! str_contains($value, ',') + ) { + return $value; + } + + if (is_json_structure($value)) { + $collection = Collection::make(json_decode($value, true)); + } else { + $collection = Collection::make( + is_array($value) + ? $value + : explode(',', $value) + ); + } + + $collectionTypes = $propertyType->getCollectionValueTypes(); + + $preferredCollectionType = reset($collectionTypes); + $preferredCollectionTypeClass = $preferredCollectionType ? $preferredCollectionType->getClassName() : null; + + $collection = $collection->map(fn ($value) => is_string($value) ? trim($value) : $value) + ->filter(fn($item) => !blank($item)); + + if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { + if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { + $collectionTypeModelClasses = array_filter( + array_map(fn (Type $type) => $type->getClassName(), $collectionTypes), + fn ($typeClass) => is_a($typeClass, Model::class, true) + ); + + $collection = (new ModelPropertyMapper())->resolve( + $collectionTypeModelClasses, + $key, + $collection, + $attributes, + $properties + ); + } else { + $collection = $collection->map( + fn ($item) => is_array($item) + ? new $preferredCollectionTypeClass(...$item) + : new $preferredCollectionTypeClass($item) + ); + } + } + + if ($propertyType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { + $collection = $collection->all(); + } + + return $collection; + } +} diff --git a/src/PropertyMappers/GenericObjectPropertyMapper.php b/src/PropertyMappers/GenericObjectPropertyMapper.php new file mode 100644 index 0000000..8e98cfc --- /dev/null +++ b/src/PropertyMappers/GenericObjectPropertyMapper.php @@ -0,0 +1,36 @@ +getClassName() === stdClass::class + && (is_array($value) || is_json_structure($value)); + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + if (is_array($value)) { + return (object) $value; + } + + return json_decode($value); + } +} diff --git a/src/PropertyMappers/ModelPropertyMapper.php b/src/PropertyMappers/ModelPropertyMapper.php new file mode 100644 index 0000000..e58ac71 --- /dev/null +++ b/src/PropertyMappers/ModelPropertyMapper.php @@ -0,0 +1,150 @@ +getClassName() === Model::class + || is_subclass_of($preferredType->getClassName(), Model::class); + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>|null $resolveModelAttribute */ + $resolveModelAttribute = $attributes + ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ResolveModel::class) + ->first(); + + /** @var \OpenSoutheners\LaravelDto\Attributes\BindModel|null $resolveModelAttribute */ + $resolveModelAttribute = $resolveModelAttribute + ? $resolveModelAttribute->newInstance() + : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($key)); + + /** @var array|null $modelWithAttributes */ + $modelWithAttributes = $attributes + ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ModelWith::class) + ->mapWithKeys(fn (ReflectionAttribute $reflection) => [$reflection->newInstance()->type => $reflection->newInstance()->relations]) + ->toArray(); + + $modelClass = Collection::make($types) + ->map(fn (Type $type): string => $type->getClassName()) + ->filter(fn (string $typeClass): bool => is_a($typeClass, Model::class, true)) + ->unique() + ->values() + ->toArray(); + + $modelType = count($modelClass) === 1 ? reset($modelClass) : $modelClass; + $valueClass = null; + + if (is_object($value) && ! $value instanceof Collection) { + $valueClass = get_class($value); + $modelType = is_array($types) ? ($modelClass[$valueClass] ?? null) : $valueClass; + } + + if ( + (! is_array($modelType) && $modelType === Model::class) + || ($resolveModelAttribute && is_array($modelType)) + ) { + $modelType = $resolveModelAttribute->getMorphModel( + $key, + $properties, + $types === Model::class ? [] : (array) $types + ); + } + + if (! is_countable($modelType) || count($modelType) === 1) { + return $this->resolveIntoModelInstance( + $value, + ! is_countable($modelType) ? $modelType : $modelType[0], + $key, + $modelWithAttributes, + $resolveModelAttribute + ); + } + + return Collection::make(array_map( + function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { + if (!is_null($valueB)) { + $lastNonValue = $valueB; + } + + return [$valueA, $valueB ?? $lastNonValue]; + }, + $value instanceof Collection ? $value->all() : (array) $value, + (array) $modelType + ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => + $this->resolveIntoModelInstance($keys, $model, $key, $modelWithAttributes, $resolveModelAttribute) + ); + } + + /** + * Get model instance(s) for model class and given IDs. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $model + * @param string|int|array|\Illuminate\Database\Eloquent\Model $id + * @param string|\Illuminate\Database\Eloquent\Model $usingAttribute + */ + protected function getModelInstance(string $model, mixed $id, mixed $usingAttribute, array $with) + { + if (is_a($usingAttribute, $model)) { + return $usingAttribute; + } + + if (is_a($id, $model)) { + return empty($with) ? $id : $id->loadMissing($with); + } + + $baseQuery = $model::query()->when( + $usingAttribute, + fn (Builder $query) => is_iterable($id) ? $query->whereIn($usingAttribute, $id) : $query->where($usingAttribute, $id), + fn (Builder $query) => $query->whereKey($id) + ); + + if (count($with) > 0) { + $baseQuery->with($with); + } + + if (is_iterable($id)) { + return $baseQuery->get(); + } + + return $baseQuery->first(); + } + + /** + * Resolve model class strings and keys into instances. + * + * @param array $withAttributes + */ + protected function resolveIntoModelInstance(mixed $keys, string $modelClass, string $propertyKey, array $withAttributes = [], ?ResolveModel $bindingAttribute = null): mixed + { + $usingAttribute = null; + $with = []; + + if ($bindingAttribute) { + $with = $withAttributes[$modelClass] ?? []; + $usingAttribute = $bindingAttribute->getBindingAttribute($propertyKey, $modelClass, $with); + } + + return $this->getModelInstance($modelClass, $keys, $usingAttribute, $with); + } +} diff --git a/src/PropertyMappers/ObjectPropertyMapper.php b/src/PropertyMappers/ObjectPropertyMapper.php new file mode 100644 index 0000000..8a4983b --- /dev/null +++ b/src/PropertyMappers/ObjectPropertyMapper.php @@ -0,0 +1,48 @@ +getClassName(); + + if (!$preferredType->getClassName()) { + return false; + } + + if (!$preferredTypeClass || !class_exists($preferredTypeClass) || !(new ReflectionClass($preferredTypeClass))->isInstantiable()) { + return false; + } + + return (is_array($value) && is_string(array_key_first($value))) || is_json_structure($value); + } + + /** + * Resolve mapper that runs once assert returns true. + * + * @param array $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + { + $preferredType = reset($types); + $preferredTypeClass = $preferredType->getClassName(); + + if (is_array($value) && is_string(array_key_first($value))) { + return new $preferredTypeClass(...$value); + } + + return new $preferredTypeClass(...json_decode($value, true)); + } +} diff --git a/src/PropertyMappers/PropertyMapper.php b/src/PropertyMappers/PropertyMapper.php new file mode 100644 index 0000000..ed71628 --- /dev/null +++ b/src/PropertyMappers/PropertyMapper.php @@ -0,0 +1,22 @@ + $types + * @param Collection<\ReflectionAttribute> $attributes + */ + public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed; +} diff --git a/src/DataTransferObject.php b/src/SerializableObject.php similarity index 74% rename from src/DataTransferObject.php rename to src/SerializableObject.php index dadb1af..8cee7fa 100644 --- a/src/DataTransferObject.php +++ b/src/SerializableObject.php @@ -5,7 +5,6 @@ use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model; -use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Collection; @@ -14,40 +13,8 @@ use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; use Symfony\Component\PropertyInfo\Type; -abstract class DataTransferObject implements Arrayable +abstract class SerializableObject implements Arrayable { - /** - * Initialise data transfer object from a request. - */ - public static function fromRequest(Request|FormRequest $request) - { - app()->bind('dto.context.booted', fn () => static::class); - - return static::fromArray( - array_merge( - is_object($request->route()) ? $request->route()->parameters() : [], - $request instanceof FormRequest - ? $request->validated() - : $request->all(), - ) - ); - } - - /** - * Initialise data transfer object from array. - */ - public static function fromArray(...$args): static - { - $propertiesMapper = new PropertiesMapper(array_merge(...$args), static::class); - - $propertiesMapper->run(); - - return tap( - new static(...$propertiesMapper->get()), - fn (self $instance) => $instance->initialise() - ); - } - /** * Check if the following property is filled. */ @@ -70,8 +37,6 @@ public function filled(string $property): bool return $requestHasProperty; } - $propertyInfoExtractor = PropertiesMapper::propertyInfoExtractor(); - $reflection = new \ReflectionClass($this); $classProperty = match (true) { @@ -80,7 +45,7 @@ public function filled(string $property): bool default => throw new Exception("Properties '{$property}' or '{$camelProperty}' doesn't exists on class instance."), }; - $classPropertyTypes = $propertyInfoExtractor->getTypes(get_class($this), $classProperty); + [$classPropertyTypes] = ObjectMapper::getPropertiesInfoFrom(get_class($this), $classProperty); $reflectionProperty = $reflection->getProperty($classProperty); $propertyValue = $reflectionProperty->getValue($this); @@ -111,45 +76,7 @@ public function filled(string $property): bool return true; } - - /** - * Initialise data transfer object (defaults, etc). - */ - public function initialise(): static - { - $this->withDefaults(); - - return $this; - } - - /** - * Add default data to data transfer object. - * - * @codeCoverageIgnore - */ - public function withDefaults(): void - { - // - } - - /** - * Call dump on this data transfer object then return itself. - */ - public function dump(): self - { - dump($this); - - return $this; - } - - /** - * Call dd on this data transfer object. - */ - public function dd(): void - { - dd($this); - } - + /** * Get the instance as an array. * @@ -160,51 +87,51 @@ public function toArray() /** @var array<\ReflectionProperty> $properties */ $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); $newPropertiesArr = []; - + foreach ($properties as $property) { if (! $this->filled($property->name) && count($property->getAttributes(WithDefaultValue::class)) === 0) { continue; } - + $propertyValue = $property->getValue($this) ?? $property->getDefaultValue(); - + if ($propertyValue instanceof Arrayable) { $propertyValue = $propertyValue->toArray(); } - + if ($propertyValue instanceof \stdClass) { $propertyValue = (array) $propertyValue; } - + $newPropertiesArr[Str::snake($property->name)] = $propertyValue; } - + return $newPropertiesArr; } - + public function __serialize(): array { $reflection = new \ReflectionClass($this); - + /** @var array<\ReflectionProperty> $properties */ $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); - + $serialisableArr = []; - + foreach ($properties as $property) { $key = $property->getName(); $value = $property->getValue($this); - + /** @var array<\ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>> $propertyModelBindingAttribute */ $propertyModelBindingAttribute = $property->getAttributes(BindModel::class); $propertyModelBindingAttribute = reset($propertyModelBindingAttribute); - + $propertyModelBindingAttributeName = null; - + if ($propertyModelBindingAttribute) { $propertyModelBindingAttributeName = $propertyModelBindingAttribute->newInstance()->using; } - + $serialisableArr[$key] = match (true) { $value instanceof Model => $value->getAttribute($propertyModelBindingAttributeName ?? $value->getRouteKeyName()), $value instanceof Collection => $value->first() instanceof Model ? $value->map(fn (Model $model) => $model->getAttribute($propertyModelBindingAttributeName ?? $model->getRouteKeyName()))->join(',') : $value->join(','), @@ -214,26 +141,24 @@ public function __serialize(): array default => $value, }; } - + return $serialisableArr; } - + /** * Called during unserialization of the object. */ public function __unserialize(array $data): void { $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); - - $propertiesMapper = new PropertiesMapper(array_merge($data), static::class); - - $propertiesMapper->run(); - - $data = $propertiesMapper->get(); - + + $propertiesMapper = new ObjectMapper(array_merge($data), static::class); + + $data = $propertiesMapper->run(); + foreach ($properties as $property) { $key = $property->getName(); - + $this->{$key} = $data[$key] ?? $property->getDefaultValue(); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 973fa17..40fc0d2 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,11 +2,13 @@ namespace OpenSoutheners\LaravelDto; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; use OpenSoutheners\LaravelDto\Commands\DtoTypescriptGenerateCommand; use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; +use OpenSoutheners\LaravelDto\PropertyMappers; class ServiceProvider extends BaseServiceProvider { @@ -25,17 +27,26 @@ public function boot() $this->commands([DtoMakeCommand::class, DtoTypescriptGenerateCommand::class]); } - $this->app->bind('dto.context.booted', fn () => ''); + ObjectMapper::registerMapper([ + new PropertyMappers\ModelPropertyMapper, + new PropertyMappers\CollectionPropertyMapper, + new PropertyMappers\ObjectPropertyMapper, + new PropertyMappers\GenericObjectPropertyMapper, + new PropertyMappers\CarbonPropertyMapper, + new PropertyMappers\BackedEnumPropertyMapper, + ]); - $this->app->beforeResolving( - DataTransferObject::class, - function ($dataClass, $parameters, $app) { - /** @var \Illuminate\Foundation\Application $app */ - $app->scoped($dataClass, fn () => $dataClass::fromRequest( - app(is_subclass_of($dataClass, ValidatedDataTransferObject::class) ? $dataClass::request() : Request::class) - )); - } - ); + $this->app->bind(Authenticatable::class, fn ($app) => $app->get('auth')->user()); + + // $this->app->beforeResolving( + // DataTransferObject::class, + // function ($dataClass, $parameters, $app) { + // /** @var \Illuminate\Foundation\Application $app */ + // $app->scoped($dataClass, fn () => $dataClass::fromRequest( + // app(is_subclass_of($dataClass, ValidatedDataTransferObject::class) ? $dataClass::request() : Request::class) + // )); + // } + // ); } /** diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index 0503a60..7e2fb00 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -54,16 +54,12 @@ public function generate(): void $normalisesPropertiesKeys = true; } - /** @var array<\ReflectionProperty> $properties */ - $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); - $propertyInfoExtractor = PropertiesMapper::propertyInfoExtractor(); + $properties = ObjectMapper::getPropertiesInfoFrom($this->dataTransferObject); $exportedType = $this->getExportTypeName($reflection); $exportAsString = "export type {$exportedType} = {\n"; - foreach ($properties as $property) { - /** @var array<\Symfony\Component\PropertyInfo\Type> $propertyTypes */ - $propertyTypes = $propertyInfoExtractor->getTypes($this->dataTransferObject, $property->getName()) ?? []; + foreach ($properties as $propertyName => $propertyTypes) { $propertyType = reset($propertyTypes); $propertyTypeClass = $propertyType ? $propertyType->getClassName() : null; @@ -73,16 +69,15 @@ public function generate(): void } $propertyTypeAsString = $this->extractTypeFromPropertyType($propertyType); - $propertyKeyAsString = $property->getName(); if ($normalisesPropertiesKeys) { - $propertyKeyAsString = Str::snake($propertyKeyAsString); + $propertyKeyAsString = Str::snake($propertyName); $propertyKeyAsString .= is_subclass_of($propertyTypeClass, Model::class) ? '_id' : ''; } $nullMark = $this->isNullableProperty( $propertyType, - $property->getName(), + $propertyName, $constructorParameters ) ? '?' : ''; diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..c642534 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,7 @@ + Date: Thu, 22 May 2025 12:24:10 +0200 Subject: [PATCH 02/13] wip --- CHANGELOG.md | 13 ++ src/Attributes/Authenticated.php | 12 ++ src/Attributes/Inject.php | 18 ++- src/Attributes/ModelWith.php | 2 +- src/Attributes/Validate.php | 3 + src/Contracts/DataTransferObject.php | 8 + src/Contracts/ValidatedDataTransferObject.php | 24 --- src/DataTransferObjects/MappingValue.php | 43 +++++ src/DynamicMapper.php | 79 +++++++--- src/Enums/BuiltInType.php | 48 ++++++ src/ObjectMapper.php | 73 ++++----- .../BackedEnumPropertyMapper.php | 23 ++- src/PropertyMappers/CarbonPropertyMapper.php | 27 ++-- .../CollectionPropertyMapper.php | 56 +++---- .../GenericObjectPropertyMapper.php | 20 +-- src/PropertyMappers/ModelPropertyMapper.php | 66 ++++---- src/PropertyMappers/ObjectPropertyMapper.php | 148 ++++++++++++++++-- src/PropertyMappers/PropertyMapper.php | 12 +- src/ServiceProvider.php | 84 +++++++--- tests/Integration/DataTransferObjectTest.php | 30 ++-- .../ValidatedDataTransferObjectTest.php | 17 +- tests/Unit/DataTransferObjectTest.php | 68 +++++--- .../app/DataTransferObjects/CreateComment.php | 5 +- .../CreateManyPostData.php | 4 +- .../DataTransferObjects/CreatePostData.php | 21 +-- .../DataTransferObjects/UpdatePostData.php | 4 +- .../UpdatePostWithDefaultData.php | 14 +- .../UpdatePostWithRouteBindingData.php | 21 ++- .../UpdatePostWithTags.php | 4 +- .../app/DataTransferObjects/UpdateTagData.php | 22 +-- workbench/routes/api.php | 4 +- 31 files changed, 637 insertions(+), 336 deletions(-) create mode 100644 src/Attributes/Authenticated.php create mode 100644 src/Contracts/DataTransferObject.php delete mode 100644 src/Contracts/ValidatedDataTransferObject.php create mode 100644 src/DataTransferObjects/MappingValue.php create mode 100644 src/Enums/BuiltInType.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fd43a70..57ee8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.0.0] - 2025-05-20 + +### Added + +- Attribute `OpenSoutheners\LaravelDto\Attributes\Inject` to inject container stuff +- Attribute `OpenSoutheners\LaravelDto\Attributes\Authenticated` that uses base `Illuminate\Container\Attributes\Authenticated` to inject current authenticated user +- Ability to register custom mappers (extending package functionality) + +### Removed + +- Abstract class `OpenSoutheners\LaravelDto\DataTransferObject` (using POPO which means _Plain Old Php Objects_) +- Attribute `OpenSoutheners\LaravelDto\Attributes\WithDefaultValue` (when using with `Illuminate\Contracts\Auth\Authenticatable` can be replaced by `OpenSoutheners\LaravelDto\Attributes\Authenticated`) + ## [3.7.0] - 2025-03-04 ### Added diff --git a/src/Attributes/Authenticated.php b/src/Attributes/Authenticated.php new file mode 100644 index 0000000..1743288 --- /dev/null +++ b/src/Attributes/Authenticated.php @@ -0,0 +1,12 @@ +make($attribute->value); + } } diff --git a/src/Attributes/ModelWith.php b/src/Attributes/ModelWith.php index ec70bd0..e377d32 100644 --- a/src/Attributes/ModelWith.php +++ b/src/Attributes/ModelWith.php @@ -4,7 +4,7 @@ use Attribute; -#[Attribute(Attribute::TARGET_PROPERTY && Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] class ModelWith { public function __construct(public string|array $relations, public ?string $type = null) diff --git a/src/Attributes/Validate.php b/src/Attributes/Validate.php index 21c91db..c6eac74 100644 --- a/src/Attributes/Validate.php +++ b/src/Attributes/Validate.php @@ -2,6 +2,9 @@ namespace OpenSoutheners\LaravelDto\Attributes; +use Attribute; + +#[Attribute(Attribute::TARGET_CLASS)] class Validate { public function __construct(public string $value) diff --git a/src/Contracts/DataTransferObject.php b/src/Contracts/DataTransferObject.php new file mode 100644 index 0000000..5cd57dd --- /dev/null +++ b/src/Contracts/DataTransferObject.php @@ -0,0 +1,8 @@ + + */ + public readonly Collection $attributes; + + /** + * @param class-string|null $objectClass + * @param array|null $types + */ + public function __construct( + public readonly mixed $data, + public readonly BuiltInType $typeFromData, + public readonly ?string $objectClass = null, + public readonly ?array $types = null, + public readonly ?ReflectionClass $class = null, + public readonly ?ReflectionProperty $property = null, + ) { + $this->preferredType = $types ? (reset($types) ?? null) : null; + + $this->preferredTypeClass = $this->preferredType ? ($this->preferredType->getClassName() ?: $objectClass) : $objectClass; + + $this->attributes = Collection::make($class ? $class->getAttributes() : []); + } +} diff --git a/src/DynamicMapper.php b/src/DynamicMapper.php index 140fb10..821d016 100644 --- a/src/DynamicMapper.php +++ b/src/DynamicMapper.php @@ -2,19 +2,25 @@ namespace OpenSoutheners\LaravelDto; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; +use OpenSoutheners\LaravelDto\Enums\BuiltInType; use ReflectionClass; use ReflectionProperty; final class DynamicMapper { - protected array $data; + protected mixed $data; protected ?string $dataClass = null; + protected ?string $parentClass = null; + + protected ?string $property = null; + + protected array $propertyTypes = []; + public function __construct(mixed $input) { if (is_object($input)) { @@ -30,13 +36,14 @@ protected function extractProperties(object $input): array $extraction = []; foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $property->isReadOnly(); $extraction[$property->getName()] = $property->getValue($input); } return $extraction; } - protected function takeDataFrom(mixed $input): array + protected function takeDataFrom(mixed $input): mixed { return match (true) { $input instanceof Request => array_merge( @@ -44,36 +51,68 @@ protected function takeDataFrom(mixed $input): array $input instanceof FormRequest ? $input->validated() : $input->all() ), is_object($input) => $this->extractProperties($input), - default => (array) $input, + default => $input, }; } + public function through(string|object $value, string $property, array $types): static + { + $this->parentClass = is_object($value) ? get_class($value) : $value; + + $this->property = $property; + + $this->propertyTypes = $types; + + return $this; + } + /** - * @template T - * @param class-string + * @template T of object + * @param class-string $output * @return T */ public function to(?string $output = null) { - if ($output && is_a($output, Model::class, true)) { - /** @var Model $model */ - $model = new $output; + // TODO: Move to ModelMapper class + // if ($output && is_a($output, Model::class, true)) { + // /** @var Model $model */ + // $model = new $output; - foreach ($this->data as $key => $value) { - if ($model->isRelation($key) && $model->$key() instanceof BelongsTo) { - $model->$key()->associate($value); - } + // foreach ($this->data as $key => $value) { + // if ($model->isRelation($key) && $model->$key() instanceof BelongsTo) { + // $model->$key()->associate($value); + // } - $model->fill([$key => $value]); - } + // $model->fill([$key => $value]); + // } - return $model; - } + // return $model; + // } $output ??= $this->dataClass; - $propertiesMapper = new ObjectMapper($this->data, $output); + $mappedValue = $this->data; + + $reflectionClass = $this->parentClass || $output ? new ReflectionClass($this->parentClass ?: $output) : null; + $reflectionProperty = $reflectionClass && $this->property ? $reflectionClass->getProperty($this->property) : null; + + $mappingDataValue = new MappingValue( + data: $this->data, + typeFromData: BuiltInType::guess($this->data), + types: $this->propertyTypes, + objectClass: $output, + class: $reflectionClass, + property: $reflectionProperty, + ); + + foreach (ServiceProvider::getMappers() as $mapper) { + if ($mapper->assert($mappingDataValue)) { + $mappedValue = $mapper->resolve($mappingDataValue); + + break; + } + } - return new $output(...$propertiesMapper->run()); + return $mappedValue; } } diff --git a/src/Enums/BuiltInType.php b/src/Enums/BuiltInType.php new file mode 100644 index 0000000..7911344 --- /dev/null +++ b/src/Enums/BuiltInType.php @@ -0,0 +1,48 @@ + $loops) { + $type = $types[$loops]; + + $truth = $type instanceof static ? $type === $this : $type === $this->value; + $loops++; + } + + return $truth; + } +} diff --git a/src/ObjectMapper.php b/src/ObjectMapper.php index e409847..43b4441 100644 --- a/src/ObjectMapper.php +++ b/src/ObjectMapper.php @@ -2,6 +2,7 @@ namespace OpenSoutheners\LaravelDto; +use Illuminate\Contracts\Container\ContextualAttribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Str; @@ -90,69 +91,63 @@ public function run(): array continue; } - $preferredType = reset($propertyTypes); - $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); - // TODO: for models - $propertyTypesModelClasses = array_filter($propertyTypesClasses, fn ($typeClass) => is_a($typeClass, Model::class, true)); - $preferredTypeClass = $preferredType->getClassName(); - /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ $propertyAttributes = Collection::make( $this->reflector->getProperty($key)->getAttributes() ); - $propertyAttributesDefaultValue = $propertyAttributes->filter( - fn (ReflectionAttribute $attribute) => $attribute->getName() === WithDefaultValue::class - )->first(); - - $defaultValue = null; - - if (! $value && $propertyAttributesDefaultValue) { - $defaultValue = $propertyAttributesDefaultValue->newInstance()->value; - } - - $injectAttribute = $propertyAttributes->filter( - fn (ReflectionAttribute $attribute) => $attribute->getName() === Inject::class + $containerAttribute = $propertyAttributes->filter( + fn (ReflectionAttribute $attribute) => is_subclass_of($attribute->getName(), ContextualAttribute::class) )->first(); - if ($injectAttribute) { - $this->data[$key] = app($injectAttribute->newInstance()->value); - + if ($containerAttribute) { + $this->data[$key] = app()->resolveFromAttribute($containerAttribute); + continue; } - $value ??= $defaultValue; - if (is_null($value)) { continue; } + $preferredType = reset($propertyTypes); + $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); + $preferredTypeClass = $preferredType->getClassName(); + if ( - $preferredTypeClass - && ! is_array($value) - && ! $preferredType->isCollection() - && $preferredTypeClass !== Collection::class - && ! is_a($preferredTypeClass, Model::class, true) - && (is_a($value, $preferredTypeClass, true) - || (is_object($value) && in_array(get_class($value), $propertyTypesClasses))) + count(static::$mappers) === 0 + || ($preferredTypeClass + && ! is_array($value) + && ! $preferredType->isCollection() + && $preferredTypeClass !== Collection::class + && ! is_a($preferredTypeClass, Model::class, true) + && (is_a($value, $preferredTypeClass, true) + || (is_object($value) && in_array(get_class($value), $propertyTypesClasses)))) ) { $this->data[$key] = $value; continue; } + + $this->data[$key] = map($value)->to($preferredTypeClass); + } - foreach (static::$mappers as $mapper) { - if ($mapper->assert($preferredType, $value)) { - $this->data[$key] = $mapper->resolve($propertyTypes, $key, $value, $propertyAttributes, $this->properties); - - break; - } + return $this->data; + } + + public function mapFromType(string $key, Type $preferredType, mixed $value, array $types, Collection $attributes): mixed + { + $mappedValue = $value; + + foreach (static::$mappers as $mapper) { + if ($mapper->assert($preferredType, $value)) { + $mappedValue = $mapper->resolve($types, $key, $value, $attributes, $this->properties); - $this->data[$key] = $value; + break; } } - - return $this->data; + + return $mappedValue; } /** diff --git a/src/PropertyMappers/BackedEnumPropertyMapper.php b/src/PropertyMappers/BackedEnumPropertyMapper.php index 07e8088..2a4128b 100644 --- a/src/PropertyMappers/BackedEnumPropertyMapper.php +++ b/src/PropertyMappers/BackedEnumPropertyMapper.php @@ -3,30 +3,27 @@ namespace OpenSoutheners\LaravelDto\PropertyMappers; use BackedEnum; -use Illuminate\Support\Collection; -use Symfony\Component\PropertyInfo\Type; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; final class BackedEnumPropertyMapper implements PropertyMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - return is_subclass_of($preferredType->getClassName(), BackedEnum::class); + return is_subclass_of($mappingValue->preferredTypeClass, BackedEnum::class); } - + /** * Resolve mapper that runs once assert returns true. - * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public function resolve(MappingValue $mappingValue): mixed { - $preferredType = reset($types); - $preferredTypeClass = $preferredType->getClassName(); - - return $preferredTypeClass::tryFrom($value) ?? (count($types) > 1 ? $value : null); + return $mappingValue->preferredTypeClass::tryFrom($mappingValue->data) ?? ( + count($mappingValue->types) > 1 + ? $mappingValue->data + : null + ); } } diff --git a/src/PropertyMappers/CarbonPropertyMapper.php b/src/PropertyMappers/CarbonPropertyMapper.php index 171478c..5238bcb 100644 --- a/src/PropertyMappers/CarbonPropertyMapper.php +++ b/src/PropertyMappers/CarbonPropertyMapper.php @@ -5,34 +5,35 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Illuminate\Support\Carbon; -use Illuminate\Support\Collection; -use Symfony\Component\PropertyInfo\Type; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; +use OpenSoutheners\LaravelDto\Enums\BuiltInType; final class CarbonPropertyMapper implements PropertyMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - return $preferredType->getClassName() === CarbonInterface::class - || is_subclass_of($preferredType->getClassName(), CarbonInterface::class); + return $mappingValue->typeFromData->assert(BuiltInType::String, BuiltInType::Integer) + && ($mappingValue->preferredTypeClass === CarbonInterface::class + || is_subclass_of($mappingValue->preferredTypeClass, CarbonInterface::class)); } /** * Resolve mapper that runs once assert returns true. - * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public function resolve(MappingValue $mappingValue): mixed { - $preferredType = reset($types); + $dateValue = match (true) { + $mappingValue->typeFromData === BuiltInType::Integer || is_numeric($mappingValue->data) => Carbon::createFromTimestamp($mappingValue->data), + default => Carbon::make($mappingValue->data), + }; - if ($preferredType === CarbonImmutable::class) { - return CarbonImmutable::make($value); + if ($mappingValue->preferredType === CarbonImmutable::class) { + $dateValue->toImmutable(); } - return Carbon::make($value); + return $dateValue; } } diff --git a/src/PropertyMappers/CollectionPropertyMapper.php b/src/PropertyMappers/CollectionPropertyMapper.php index 7aa57a5..a60c4e4 100644 --- a/src/PropertyMappers/CollectionPropertyMapper.php +++ b/src/PropertyMappers/CollectionPropertyMapper.php @@ -5,50 +5,55 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use Symfony\Component\PropertyInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; +use function OpenSoutheners\LaravelDto\map; final class CollectionPropertyMapper implements PropertyMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - return $preferredType->isCollection() - || $preferredType->getClassName() === Collection::class - || $preferredType->getClassName() === EloquentCollection::class; + return $mappingValue->preferredType?->isCollection() + || $mappingValue->preferredTypeClass === Collection::class + || $mappingValue->preferredTypeClass === EloquentCollection::class; } /** * Resolve mapper that runs once assert returns true. * - * @param array $types + * @param string[]|string $types * @param Collection<\ReflectionAttribute> $attributes + * @param array $properties */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public function resolve(MappingValue $mappingValue): mixed { - if ($value instanceof Collection) { - return $value instanceof EloquentCollection ? $value->toBase() : $value; + if ($mappingValue->objectClass === Collection::class) { + return $mappingValue->data instanceof EloquentCollection + ? $mappingValue->data->toBase() + : $mappingValue->data; } - $propertyType = reset($types); + $propertyType = reset($mappingValue->types); if ( - count(array_filter($types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 - && ! str_contains($value, ',') + count(array_filter($mappingValue->types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 + && ! str_contains($mappingValue->types, ',') ) { - return $value; + return $mappingValue->data; } - if (is_json_structure($value)) { - $collection = Collection::make(json_decode($value, true)); + if (is_json_structure($mappingValue->data)) { + $collection = Collection::make(json_decode($mappingValue->data, true)); } else { $collection = Collection::make( - is_array($value) - ? $value - : explode(',', $value) + is_array($mappingValue->data) + ? $mappingValue->data + : explode(',', $mappingValue->data) ); } @@ -62,23 +67,10 @@ public function resolve(array $types, string $key, mixed $value, Collection $att if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { - $collectionTypeModelClasses = array_filter( - array_map(fn (Type $type) => $type->getClassName(), $collectionTypes), - fn ($typeClass) => is_a($typeClass, Model::class, true) - ); - - $collection = (new ModelPropertyMapper())->resolve( - $collectionTypeModelClasses, - $key, - $collection, - $attributes, - $properties - ); + $collection = map($mappingValue->data)->to($preferredCollectionTypeClass); } else { $collection = $collection->map( - fn ($item) => is_array($item) - ? new $preferredCollectionTypeClass(...$item) - : new $preferredCollectionTypeClass($item) + fn ($item) => map($item)->to($preferredCollectionTypeClass) ); } } diff --git a/src/PropertyMappers/GenericObjectPropertyMapper.php b/src/PropertyMappers/GenericObjectPropertyMapper.php index 8e98cfc..9b2bd01 100644 --- a/src/PropertyMappers/GenericObjectPropertyMapper.php +++ b/src/PropertyMappers/GenericObjectPropertyMapper.php @@ -2,9 +2,8 @@ namespace OpenSoutheners\LaravelDto\PropertyMappers; -use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use stdClass; -use Symfony\Component\PropertyInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; @@ -13,24 +12,21 @@ final class GenericObjectPropertyMapper implements PropertyMapper /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - return $preferredType->getClassName() === stdClass::class - && (is_array($value) || is_json_structure($value)); + return $mappingValue->preferredTypeClass === stdClass::class + && (is_array($mappingValue->data) || is_json_structure($mappingValue->data)); } /** * Resolve mapper that runs once assert returns true. - * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public function resolve(MappingValue $mappingValue): mixed { - if (is_array($value)) { - return (object) $value; + if (is_array($mappingValue->data)) { + return (object) $mappingValue->data; } - return json_decode($value); + return json_decode($mappingValue->data); } } diff --git a/src/PropertyMappers/ModelPropertyMapper.php b/src/PropertyMappers/ModelPropertyMapper.php index e58ac71..093806a 100644 --- a/src/PropertyMappers/ModelPropertyMapper.php +++ b/src/PropertyMappers/ModelPropertyMapper.php @@ -7,7 +7,9 @@ use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\ModelWith; use OpenSoutheners\LaravelDto\Attributes\ResolveModel; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use ReflectionAttribute; +use ReflectionProperty; use Symfony\Component\PropertyInfo\Type; final class ModelPropertyMapper implements PropertyMapper @@ -15,49 +17,47 @@ final class ModelPropertyMapper implements PropertyMapper /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - return $preferredType->getClassName() === Model::class - || is_subclass_of($preferredType->getClassName(), Model::class); + return $mappingValue->preferredTypeClass === Model::class + || is_subclass_of($mappingValue->preferredTypeClass, Model::class); } /** * Resolve mapper that runs once assert returns true. - * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public function resolve(MappingValue $mappingValue): mixed { - /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>|null $resolveModelAttribute */ - $resolveModelAttribute = $attributes - ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ResolveModel::class) - ->first(); + $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); + + /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ + $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); - /** @var \OpenSoutheners\LaravelDto\Attributes\BindModel|null $resolveModelAttribute */ - $resolveModelAttribute = $resolveModelAttribute - ? $resolveModelAttribute->newInstance() - : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($key)); + /** @var \OpenSoutheners\LaravelDto\Attributes\ResolveModel|null $resolveModelAttribute */ + $resolveModelAttribute = $resolveModelAttributeReflector + ? $resolveModelAttributeReflector->newInstance() + : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($mappingValue->property->getName())); - /** @var array|null $modelWithAttributes */ - $modelWithAttributes = $attributes - ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ModelWith::class) - ->mapWithKeys(fn (ReflectionAttribute $reflection) => [$reflection->newInstance()->type => $reflection->newInstance()->relations]) - ->toArray(); - - $modelClass = Collection::make($types) + $modelClass = Collection::make($mappingValue->types ?? [$mappingValue->preferredTypeClass]) ->map(fn (Type $type): string => $type->getClassName()) ->filter(fn (string $typeClass): bool => is_a($typeClass, Model::class, true)) ->unique() ->values() ->toArray(); - + $modelType = count($modelClass) === 1 ? reset($modelClass) : $modelClass; $valueClass = null; - if (is_object($value) && ! $value instanceof Collection) { - $valueClass = get_class($value); - $modelType = is_array($types) ? ($modelClass[$valueClass] ?? null) : $valueClass; + /** @var array|null $modelWithAttributes */ + $modelWithAttributes = $mappingValue->attributes + ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ModelWith::class) + ->mapWithKeys(fn (ReflectionAttribute $reflection) => [$reflection->newInstance()->type ?? $modelType => $reflection->newInstance()->relations]) + ->toArray(); + + if (is_array($modelType) && $mappingValue->objectClass === Collection::class) { + $valueClass = get_class($mappingValue->data); + + $modelType = $modelClass[array_search($valueClass, $modelClass)]; } if ( @@ -65,17 +65,17 @@ public function resolve(array $types, string $key, mixed $value, Collection $att || ($resolveModelAttribute && is_array($modelType)) ) { $modelType = $resolveModelAttribute->getMorphModel( - $key, - $properties, - $types === Model::class ? [] : (array) $types + $mappingValue->property->getName(), + $mappingValue->class->getProperties(ReflectionProperty::IS_PUBLIC), + $mappingValue->types === Model::class ? [] : (array) $mappingValue->types ); } if (! is_countable($modelType) || count($modelType) === 1) { return $this->resolveIntoModelInstance( - $value, + $mappingValue->data, ! is_countable($modelType) ? $modelType : $modelType[0], - $key, + $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute ); @@ -89,10 +89,10 @@ function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { return [$valueA, $valueB ?? $lastNonValue]; }, - $value instanceof Collection ? $value->all() : (array) $value, + $mappingValue->data instanceof Collection ? $mappingValue->data->all() : (array) $mappingValue->data, (array) $modelType ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => - $this->resolveIntoModelInstance($keys, $model, $key, $modelWithAttributes, $resolveModelAttribute) + $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute) ); } diff --git a/src/PropertyMappers/ObjectPropertyMapper.php b/src/PropertyMappers/ObjectPropertyMapper.php index 8a4983b..9bdd60c 100644 --- a/src/PropertyMappers/ObjectPropertyMapper.php +++ b/src/PropertyMappers/ObjectPropertyMapper.php @@ -2,47 +2,163 @@ namespace OpenSoutheners\LaravelDto\PropertyMappers; +use Illuminate\Contracts\Container\ContextualAttribute; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use ReflectionClass; +use ReflectionProperty; +use stdClass; +use Illuminate\Support\Str; +use OpenSoutheners\LaravelDto\Attributes\NormaliseProperties; +use ReflectionAttribute; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; +use function OpenSoutheners\LaravelDto\map; final class ObjectPropertyMapper implements PropertyMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool + public function assert(MappingValue $mappingValue): bool { - $preferredTypeClass = $preferredType->getClassName(); - - if (!$preferredType->getClassName()) { + if ( + !$mappingValue->preferredTypeClass + || $mappingValue->preferredTypeClass === stdClass::class + || !class_exists($mappingValue->preferredTypeClass) + || !(new ReflectionClass($mappingValue->preferredTypeClass))->isInstantiable() + ) { return false; } - if (!$preferredTypeClass || !class_exists($preferredTypeClass) || !(new ReflectionClass($preferredTypeClass))->isInstantiable()) { - return false; + return is_array($mappingValue->data) + && (is_string(array_key_first($mappingValue->data)) || is_json_structure($mappingValue->data)); + } + + /** + * Resolve mapper that runs once assert returns true. + */ + public function resolve(MappingValue $mappingValue): mixed + { + $data = []; + $propertiesInfo = static::getPropertiesInfoFrom($mappingValue->preferredTypeClass); + + $mappingData = is_string($mappingValue->data) ? json_decode($mappingValue->data, true) : $mappingValue->data; + + $propertiesData = array_combine( + array_map(fn ($key) => $this->normalisePropertyKey($mappingValue, $key), array_keys($mappingData)), + array_values($mappingData) + ); + + foreach ($propertiesInfo as $key => $propertyTypes) { + $value = $propertiesData[$key] ?? null; + + if (count($propertyTypes) === 0) { + $data[$key] = $value; + + continue; + } + + /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ + $propertyAttributes = Collection::make( + $mappingValue->class->getProperty($key)->getAttributes() + ); + + $containerAttribute = $propertyAttributes->filter( + fn (ReflectionAttribute $attribute) => is_subclass_of($attribute->getName(), ContextualAttribute::class) + )->first(); + + if ($containerAttribute) { + $data[$key] = app()->resolveFromAttribute($containerAttribute); + + continue; + } + + if (is_null($value)) { + continue; + } + + $preferredType = reset($propertyTypes); + $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); + $preferredTypeClass = $preferredType->getClassName(); + + if ( + $preferredTypeClass + && ! is_array($value) + && ! $preferredType->isCollection() + && $preferredTypeClass !== Collection::class + && ! is_a($preferredTypeClass, Model::class, true) + && (is_a($value, $preferredTypeClass, true) + || (is_object($value) && in_array(get_class($value), $propertyTypesClasses))) + ) { + $data[$key] = $value; + + continue; + } + + $data[$key] = map($value) + ->through($mappingValue->preferredTypeClass, $key, $propertyTypes) + ->to($preferredTypeClass); } - return (is_array($value) && is_string(array_key_first($value))) || is_json_structure($value); + return new $mappingValue->preferredTypeClass(...$data); } + + /** + * Normalise property key using camel case or original. + */ + protected function normalisePropertyKey(MappingValue $mappingValue, string $key): ?string + { + $normaliseProperty = count($mappingValue->class->getAttributes(NormaliseProperties::class)) > 0 + ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); + + if (! $normaliseProperty) { + return $key; + } + + if (Str::endsWith($key, '_id')) { + $key = Str::replaceLast('_id', '', $key); + } + + $camelKey = Str::camel($key); + return match (true) { + property_exists($mappingValue->preferredTypeClass, $key) => $key, + property_exists($mappingValue->preferredTypeClass, $camelKey) => $camelKey, + default => null + }; + } + /** - * Resolve mapper that runs once assert returns true. + * Get instance of property info extractor. * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes + * @return array> */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed + public static function getPropertiesInfoFrom(string $class, ?string $property = null): array { - $preferredType = reset($types); - $preferredTypeClass = $preferredType->getClassName(); + $phpStanExtractor = new PhpStanExtractor(); + $reflectionExtractor = new ReflectionExtractor(); + + $extractor = new PropertyInfoExtractor( + [$reflectionExtractor], + [$phpStanExtractor, $reflectionExtractor], + ); + + if ($property) { + return [$property => $extractor->getTypes($class, $property) ?? []]; + } + + $propertiesInfo = []; - if (is_array($value) && is_string(array_key_first($value))) { - return new $preferredTypeClass(...$value); + foreach ($extractor->getProperties($class) as $key) { + $propertiesInfo[$key] = $extractor->getTypes($class, $key) ?? []; } - return new $preferredTypeClass(...json_decode($value, true)); + return $propertiesInfo; } } diff --git a/src/PropertyMappers/PropertyMapper.php b/src/PropertyMappers/PropertyMapper.php index ed71628..109b092 100644 --- a/src/PropertyMappers/PropertyMapper.php +++ b/src/PropertyMappers/PropertyMapper.php @@ -2,21 +2,17 @@ namespace OpenSoutheners\LaravelDto\PropertyMappers; -use Illuminate\Support\Collection; -use Symfony\Component\PropertyInfo\Type; +use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; interface PropertyMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(Type $preferredType, mixed $value): bool; - + public function assert(MappingValue $mappingValue): bool; + /** * Resolve mapper that runs once assert returns true. - * - * @param array $types - * @param Collection<\ReflectionAttribute> $attributes */ - public function resolve(array $types, string $key, mixed $value, Collection $attributes, array $properties): mixed; + public function resolve(MappingValue $mappingValue): mixed; } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 40fc0d2..d51b451 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,16 +2,30 @@ namespace OpenSoutheners\LaravelDto; -use Illuminate\Contracts\Auth\Authenticatable; +use Exception; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider as BaseServiceProvider; +use OpenSoutheners\LaravelDto\Attributes\Validate; use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; use OpenSoutheners\LaravelDto\Commands\DtoTypescriptGenerateCommand; -use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use OpenSoutheners\LaravelDto\PropertyMappers; +use OpenSoutheners\LaravelDto\PropertyMappers\PropertyMapper; +use ReflectionClass; class ServiceProvider extends BaseServiceProvider { + protected static $mappers = [ + PropertyMappers\CollectionPropertyMapper::class, + PropertyMappers\ModelPropertyMapper::class, + + PropertyMappers\CarbonPropertyMapper::class, + PropertyMappers\BackedEnumPropertyMapper::class, + PropertyMappers\GenericObjectPropertyMapper::class, + PropertyMappers\ObjectPropertyMapper::class, + + ]; + /** * Bootstrap any application services. * @@ -26,27 +40,23 @@ public function boot() $this->commands([DtoMakeCommand::class, DtoTypescriptGenerateCommand::class]); } - - ObjectMapper::registerMapper([ - new PropertyMappers\ModelPropertyMapper, - new PropertyMappers\CollectionPropertyMapper, - new PropertyMappers\ObjectPropertyMapper, - new PropertyMappers\GenericObjectPropertyMapper, - new PropertyMappers\CarbonPropertyMapper, - new PropertyMappers\BackedEnumPropertyMapper, - ]); - - $this->app->bind(Authenticatable::class, fn ($app) => $app->get('auth')->user()); - // $this->app->beforeResolving( - // DataTransferObject::class, - // function ($dataClass, $parameters, $app) { - // /** @var \Illuminate\Foundation\Application $app */ - // $app->scoped($dataClass, fn () => $dataClass::fromRequest( - // app(is_subclass_of($dataClass, ValidatedDataTransferObject::class) ? $dataClass::request() : Request::class) - // )); - // } - // ); + $this->app->beforeResolving( + DataTransferObject::class, + function ($dataClass, $parameters, $app) { + /** @var \Illuminate\Foundation\Application $app */ + $app->scoped($dataClass, function () use ($dataClass, $app) { + $reflector = new ReflectionClass($dataClass); + + $validateAttributes = $reflector->getAttributes(Validate::class); + $validateAttribute = reset($validateAttributes); + + return map( + $app->make($validateAttribute ? $validateAttribute->newInstance()->value : Request::class) + )->to($dataClass); + }); + } + ); } /** @@ -58,4 +68,34 @@ public function register() { // } + + /** + * Register new dynamic mappers. + */ + public function registerMapper(string|array $mapper, bool $replacing = false): void + { + $mappers = (array) $mapper; + + static::$mappers = $replacing ? $mappers : array_merge(static::$mappers, $mapper); + } + + /** + * Get dynamic mappers. + * + * @return array + */ + public static function getMappers(): array + { + $mappers = []; + + foreach (static::$mappers as $mapper) { + $mapperInstance = new $mapper; + + if ($mapperInstance instanceof PropertyMapper) { + $mappers[] = $mapperInstance; + } + } + + return $mappers; + } } diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/Integration/DataTransferObjectTest.php index 86422bc..2c4e8f2 100644 --- a/tests/Integration/DataTransferObjectTest.php +++ b/tests/Integration/DataTransferObjectTest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Validation\Rule; use Mockery; +use Mockery\MockInterface; use Workbench\App\DataTransferObjects\CreatePostData; use Workbench\App\DataTransferObjects\UpdatePostData; use Workbench\App\DataTransferObjects\UpdatePostWithDefaultData; @@ -20,6 +21,8 @@ use Workbench\Database\Factories\PostFactory; use Workbench\Database\Factories\TagFactory; +use function OpenSoutheners\LaravelDto\map; + class DataTransferObjectTest extends TestCase { public function testDataTransferObjectFromRequest() @@ -31,8 +34,9 @@ public function testDataTransferObjectFromRequest() 'password' => '', ]); - Auth::shouldReceive('check')->andReturn(true); - Auth::shouldReceive('user')->andReturn($user); + $this->partialMock('auth', function (MockInterface $mock) use ($user) { + $mock->expects('userResolver')->andReturn(fn () => $user); + }); /** @var CreatePostFormRequest */ $mock = Mockery::mock(app(CreatePostFormRequest::class))->makePartial(); @@ -48,7 +52,7 @@ public function testDataTransferObjectFromRequest() // Not absolutely the same but does the job... app()->bind(Request::class, fn () => $mock); - $data = CreatePostData::fromRequest($mock); + $data = map($mock)->to(CreatePostData::class); $this->assertTrue($data->postStatus instanceof PostStatus); $this->assertEquals('Hello world', $data->title); @@ -70,12 +74,12 @@ public function testDataTransferObjectFromArrayWithModels() 'status' => PostStatus::Hidden->value, ]); - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => 'foo,bar,test', 'post_status' => PostStatus::Published->value, 'post_id' => 1, - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->postStatus instanceof PostStatus); $this->assertEquals('Hello world', $data->title); @@ -86,6 +90,8 @@ public function testDataTransferObjectFromArrayWithModels() public function testDataTransferObjectFilledViaRequest() { + $this->markTestSkipped('Need to reimplement filled method as a trait'); + /** @var CreatePostFormRequest */ $mock = Mockery::mock(app(Request::class))->makePartial(); @@ -99,9 +105,9 @@ public function testDataTransferObjectFilledViaRequest() $mock->shouldReceive('has')->withArgs(['postStatus'])->andReturn(true); $mock->shouldReceive('has')->withArgs(['post'])->andReturn(false); - app()->bind(Request::class, fn () => $mock); + $this->mock(Request::class, fn () => $mock); - $data = CreatePostData::fromRequest($mock); + $data = map($mock)->to(CreatePostData::class); $this->assertFalse($data->filled('tags')); $this->assertTrue($data->filled('post_status')); @@ -126,11 +132,11 @@ public function testDataTransferObjectWithoutPropertyKeysNormalisationWhenDisabl 'status' => PostStatus::Hidden->value, ]); - $data = UpdatePostData::fromArray([ + $data = map([ 'post_id' => 2, 'parent' => 1, 'tags' => 'test,hello', - ]); + ])->to(UpdatePostData::class); $this->assertTrue($data->post_id?->is($post)); $this->assertTrue($data->parent?->is($parentPost)); @@ -138,6 +144,8 @@ public function testDataTransferObjectWithoutPropertyKeysNormalisationWhenDisabl public function testDataTransferObjectWithDefaultValueAttribute() { + $this->markTestSkipped('To implement default values'); + $user = User::create([ 'email' => 'ruben@hello.com', 'password' => '1234', @@ -157,7 +165,7 @@ public function testDataTransferObjectWithDefaultValueAttribute() ]); Route::post('/posts/{post?}', function (UpdatePostWithDefaultData $data) { - return response()->json($data->toArray()); + return response()->json((array) $data); }); $response = $this->postJson('/posts', []); @@ -189,7 +197,7 @@ public function testDataTransferObjectWithDefaultValueAttributeGetsBoundWhenOneI ]); Route::post('/posts/{post}', function (UpdatePostWithDefaultData $data) { - return response()->json($data->toArray()); + return response()->json((array) $data); }); $response = $this->postJson('/posts/foo-bar', []); diff --git a/tests/Integration/ValidatedDataTransferObjectTest.php b/tests/Integration/ValidatedDataTransferObjectTest.php index 3e05b87..5dda653 100644 --- a/tests/Integration/ValidatedDataTransferObjectTest.php +++ b/tests/Integration/ValidatedDataTransferObjectTest.php @@ -8,6 +8,8 @@ use Workbench\Database\Factories\PostFactory; use Workbench\Database\Factories\TagFactory; +use function OpenSoutheners\LaravelDto\map; + class ValidatedDataTransferObjectTest extends TestCase { use RefreshDatabase; @@ -58,7 +60,7 @@ public function testValidatedDataTransferObjectGetsValidatedOnlyParameters() 'slug' => $secondTag->slug, ], ], - 'published_at' => '2023-09-06T17:35:53.000000Z', + 'publishedAt' => '2023-09-06T17:35:53.000000Z', ], true); } @@ -68,12 +70,13 @@ public function testDataTransferObjectWithModelSentDoesLoadRelationshipIfMissing TagFactory::new()->count(2) )->create(); - $data = UpdatePostWithRouteBindingData::fromArray([ + $data = map([ 'post' => $post, - ]); + ])->to(UpdatePostWithRouteBindingData::class); DB::enableQueryLog(); + $this->assertTrue($data->post->relationLoaded('tags')); $this->assertNotEmpty($data->post->tags); $this->assertCount(2, $data->post->tags); $this->assertEmpty(DB::getQueryLog()); @@ -87,9 +90,9 @@ public function testDataTransferObjectWithModelSentDoesNotRunQueriesToFetchItAga DB::enableQueryLog(); - $data = UpdatePostWithRouteBindingData::fromArray([ + $data = map([ 'post' => $post, - ]); + ])->to(UpdatePostWithRouteBindingData::class); $this->assertEmpty(DB::getQueryLog()); $this->assertTrue($data->post->is($post)); @@ -104,12 +107,12 @@ public function testDataTransferObjectCanBeSerializedAndDeserialized() TagFactory::new()->create(); TagFactory::new()->create(); - $data = UpdatePostWithRouteBindingData::fromArray([ + $data = map([ 'post' => '1', 'tags' => '1,2', 'post_status' => 'test_non_existing_status', 'published_at' => '2023-09-06 17:35:53', - ]); + ])->to(UpdatePostWithRouteBindingData::class); $serializedData = serialize($data); diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index 4b4e59e..c2a2475 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -8,17 +8,30 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Mockery; +use OpenSoutheners\LaravelDto\ObjectMapper; +use OpenSoutheners\LaravelDto\PropertyMappers; use PHPUnit\Framework\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; use Workbench\App\DataTransferObjects\CreatePostData; use Workbench\App\Enums\PostStatus; +use function OpenSoutheners\LaravelDto\map; + class DataTransferObjectTest extends TestCase { public function setUp(): void { parent::setUp(); + + ObjectMapper::registerMapper([ + new PropertyMappers\ModelPropertyMapper, + new PropertyMappers\CollectionPropertyMapper, + new PropertyMappers\ObjectPropertyMapper, + new PropertyMappers\GenericObjectPropertyMapper, + new PropertyMappers\CarbonPropertyMapper, + new PropertyMappers\BackedEnumPropertyMapper, + ]); $mockedConfig = Mockery::mock(Repository::class); @@ -29,6 +42,7 @@ public function setUp(): void $mockedAuth = Mockery::mock(AuthManager::class); $mockedAuth->shouldReceive('check')->andReturn(false); + $mockedAuth->shouldReceive('userResolver')->andReturn(fn () => null); Container::getInstance()->bind('auth', fn () => $mockedAuth); @@ -37,11 +51,11 @@ public function setUp(): void public function testDataTransferObjectFromArray() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => 'foo,bar,test', 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->postStatus instanceof PostStatus); $this->assertEquals('Hello world', $data->title); @@ -52,12 +66,12 @@ public function testDataTransferObjectFromArray() public function testDataTransferObjectFromArrayDelimitedLists() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => 'foo', 'country' => 'foo', 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertIsArray($data->tags); $this->assertIsString($data->country); @@ -65,12 +79,14 @@ public function testDataTransferObjectFromArrayDelimitedLists() public function testDataTransferObjectFilledViaClassProperties() { - $data = CreatePostData::fromArray([ + $this->markTestSkipped('To implement filled method as trait'); + + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, 'author_email' => 'me@d8vjork.com', - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->filled('tags')); $this->assertTrue($data->filled('postStatus')); @@ -81,11 +97,11 @@ public function testDataTransferObjectFilledViaClassProperties() public function testDataTransferObjectWithDefaults() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertContains('generic', $data->tags); $this->assertContains('post', $data->tags); @@ -103,14 +119,14 @@ public function testDataTransferObjectArrayWithoutTypedPropertiesGetsThroughWith 'slug' => 'traveling-guides', ]; - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => [ $helloTag, $travelingTag, ], 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertContains($helloTag, $data->tags); $this->assertContains($travelingTag, $data->tags); @@ -128,7 +144,7 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() 'email' => 'taylor@hello.com', ]; - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'subscribers' => [ @@ -136,7 +152,7 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() $taylorUser, ], 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->subscribers instanceof Collection); $this->assertContains($rubenUser, $data->subscribers); @@ -145,13 +161,13 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() public function testDataTransferObjectDatePropertiesGetMappedFromStringsIntoCarbonInstances() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, 'published_at' => '2023-09-06 17:35:53', 'content' => '{"type": "doc", "content": [{"type": "paragraph", "attrs": {"textAlign": "left"}, "content": [{"text": "dede", "type": "text"}]}]}', - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->publishedAt instanceof Carbon); $this->assertTrue(now()->isAfter($data->publishedAt)); @@ -159,12 +175,12 @@ public function testDataTransferObjectDatePropertiesGetMappedFromStringsIntoCarb public function testDataTransferObjectDatePropertiesGetMappedFromJsonStringsIntoGenericObjects() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, 'content' => '{"type": "doc", "content": [{"type": "paragraph", "attrs": {"textAlign": "left"}, "content": [{"text": "hello world", "type": "text"}]}]}', - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->content instanceof \stdClass); $this->assertObjectHasProperty('type', $data->content); @@ -172,7 +188,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromJsonStringsInto public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGenericObjects() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -193,7 +209,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGener ], ], ], - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->content instanceof \stdClass); $this->assertObjectHasProperty('type', $data->content); @@ -201,7 +217,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGener public function testDataTransferObjectDatePropertiesGetMappedFromArraysOfObjectsIntoCollectionOfGenericObjects() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -209,7 +225,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysOfObjects '2023-09-06 17:35:53', '2023-09-07 06:35:53', ], - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->dates instanceof Collection); $this->assertTrue($data->dates->first() instanceof Carbon); @@ -219,7 +235,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysOfObjects public function testDataTransferObjectDatePropertiesDoesNotGetMappedFromCollectionsToSameType() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -227,7 +243,7 @@ public function testDataTransferObjectDatePropertiesDoesNotGetMappedFromCollecti '2023-09-06 17:35:53', '2023-09-07 06:35:53', ]), - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->dates instanceof Collection); $this->assertFalse($data->dates->first() instanceof Carbon); @@ -236,7 +252,7 @@ public function testDataTransferObjectDatePropertiesDoesNotGetMappedFromCollecti public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFromArray() { - $data = CreateManyPostData::fromArray([ + $data = map([ 'posts' => [ [ 'title' => 'Hello world', @@ -257,7 +273,7 @@ public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFrom ], ], ], - ]); + ])->to(CreateManyPostData::class); $this->assertInstanceOf(Collection::class, $data->posts); @@ -274,13 +290,13 @@ public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFrom public function testDataTransferObjectRetainKeysFromNestedObjectsOrArrays() { - $data = CreateComment::fromArray([ + $data = map([ 'content' => 'hello world', 'tags' => [ 'hello' => 'world', 'foo' => 'bar' ] - ]); + ])->to(CreateComment::class); $this->assertArrayHasKey('hello', $data->tags); $this->assertArrayHasKey('foo', $data->tags); diff --git a/workbench/app/DataTransferObjects/CreateComment.php b/workbench/app/DataTransferObjects/CreateComment.php index a8aad8e..61713b1 100644 --- a/workbench/app/DataTransferObjects/CreateComment.php +++ b/workbench/app/DataTransferObjects/CreateComment.php @@ -2,10 +2,9 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; -class CreateComment extends DataTransferObject +class CreateComment implements DataTransferObject { public function __construct( public string $content, diff --git a/workbench/app/DataTransferObjects/CreateManyPostData.php b/workbench/app/DataTransferObjects/CreateManyPostData.php index f045228..2efde25 100644 --- a/workbench/app/DataTransferObjects/CreateManyPostData.php +++ b/workbench/app/DataTransferObjects/CreateManyPostData.php @@ -3,9 +3,9 @@ namespace Workbench\App\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; -class CreateManyPostData extends DataTransferObject +class CreateManyPostData implements DataTransferObject { /** * @param \Illuminate\Support\Collection<\Workbench\App\DataTransferObjects\CreatePostData> $posts diff --git a/workbench/app/DataTransferObjects/CreatePostData.php b/workbench/app/DataTransferObjects/CreatePostData.php index c59d013..fef2d12 100644 --- a/workbench/app/DataTransferObjects/CreatePostData.php +++ b/workbench/app/DataTransferObjects/CreatePostData.php @@ -5,12 +5,14 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Attributes\Authenticated; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use stdClass; use Workbench\App\Enums\PostStatus; use Workbench\App\Models\Post; +use Workbench\App\Models\User; -class CreatePostData extends DataTransferObject +class CreatePostData implements DataTransferObject { public mixed $authorEmail = 'me@d8vjork.com'; @@ -25,22 +27,15 @@ public function __construct( public array|string|null $country = null, public $description = '', public ?Collection $subscribers = null, - public ?Authenticatable $currentUser = null, + #[Authenticated] + public ?User $currentUser = null, public ?Carbon $publishedAt = null, public ?stdClass $content = null, public ?Collection $dates = null, $authorEmail = null ) { + $this->tags ??= ['generic', 'post']; + $this->authorEmail = $authorEmail; } - - /** - * Add default data to data transfer object. - */ - public function withDefaults(): void - { - if (empty($this->tags)) { - $this->tags = ['generic', 'post']; - } - } } diff --git a/workbench/app/DataTransferObjects/UpdatePostData.php b/workbench/app/DataTransferObjects/UpdatePostData.php index 65e5601..e6ed743 100644 --- a/workbench/app/DataTransferObjects/UpdatePostData.php +++ b/workbench/app/DataTransferObjects/UpdatePostData.php @@ -2,10 +2,10 @@ namespace Workbench\App\DataTransferObjects; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostData extends DataTransferObject +class UpdatePostData implements DataTransferObject { /** * @param string[] $tags diff --git a/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php b/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php index 5166944..e04eeb9 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php @@ -2,24 +2,22 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Contracts\Auth\Authenticatable; -use OpenSoutheners\LaravelDto\Attributes\BindModel; -use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Attributes\Authenticated; +use OpenSoutheners\LaravelDto\Attributes\ResolveModel; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Models\Post; use Workbench\App\Models\Tag; use Workbench\App\Models\User; -class UpdatePostWithDefaultData extends DataTransferObject +class UpdatePostWithDefaultData implements DataTransferObject { /** * @param string[] $tags */ public function __construct( - #[BindModel('slug')] - #[WithDefaultValue('hello-world')] + #[ResolveModel('slug')] public Post $post, - #[WithDefaultValue(Authenticatable::class)] + #[Authenticated] public User $author, public Post|Tag|null $parent = null, public array|string|null $country = null, diff --git a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php index 3881e17..27f4b8a 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php @@ -6,35 +6,34 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\AsType; -use OpenSoutheners\LaravelDto\Attributes\BindModel; -use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Attributes\Authenticated; +use OpenSoutheners\LaravelDto\Attributes\ModelWith; +use OpenSoutheners\LaravelDto\Attributes\Validate; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use stdClass; use Workbench\App\Enums\PostStatus; use Workbench\App\Http\Requests\PostUpdateFormRequest; use Workbench\App\Models\Post; +use Workbench\App\Models\User; #[AsType('UpdatePostFormData')] -class UpdatePostWithRouteBindingData extends DataTransferObject implements ValidatedDataTransferObject +#[Validate(PostUpdateFormRequest::class)] +class UpdatePostWithRouteBindingData implements DataTransferObject { /** * @param \Illuminate\Support\Collection<\Workbench\App\Models\Tag>|null $tags */ public function __construct( - #[BindModel(with: 'tags')] + #[ModelWith(['tags'])] public Post $post, public ?string $title = null, public ?stdClass $content = null, public ?PostStatus $postStatus = null, public ?Collection $tags = null, public ?CarbonImmutable $publishedAt = null, - public ?Authenticatable $currentUser = null + #[Authenticated] + public ?User $currentUser = null ) { // } - - public static function request(): string - { - return PostUpdateFormRequest::class; - } } diff --git a/workbench/app/DataTransferObjects/UpdatePostWithTags.php b/workbench/app/DataTransferObjects/UpdatePostWithTags.php index 3526193..a631745 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithTags.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithTags.php @@ -3,10 +3,10 @@ namespace Workbench\App\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostWithTags extends DataTransferObject +class UpdatePostWithTags implements DataTransferObject { public function __construct( public Post $post, diff --git a/workbench/app/DataTransferObjects/UpdateTagData.php b/workbench/app/DataTransferObjects/UpdateTagData.php index 51a0fca..ad5c912 100644 --- a/workbench/app/DataTransferObjects/UpdateTagData.php +++ b/workbench/app/DataTransferObjects/UpdateTagData.php @@ -4,39 +4,33 @@ use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\Attributes\Authenticated; use OpenSoutheners\LaravelDto\Attributes\BindModel; +use OpenSoutheners\LaravelDto\Attributes\ResolveModel; +use OpenSoutheners\LaravelDto\Attributes\Validate; use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; -use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Http\Requests\TagUpdateFormRequest; use Workbench\App\Models\Film; use Workbench\App\Models\Post; use Workbench\App\Models\Tag; use Workbench\App\Models\User; -class UpdateTagData extends DataTransferObject implements ValidatedDataTransferObject +#[Validate(TagUpdateFormRequest::class)] +class UpdateTagData implements DataTransferObject { /** * @param \Illuminate\Support\Collection<\Workbench\App\Models\Post|\Workbench\App\Models\Film> $taggable */ public function __construct( - #[BindModel] public Tag $tag, - #[BindModel([Post::class => 'slug', Film::class])] + #[ResolveModel([Post::class => 'slug', Film::class])] public Collection $taggable, public array $taggableType, public string $name, - #[WithDefaultValue(Authenticatable::class)] + #[Authenticated] public User $authUser ) { // } - - /** - * Get form request that this data transfer object is based from. - */ - public static function request(): string - { - return TagUpdateFormRequest::class; - } } diff --git a/workbench/routes/api.php b/workbench/routes/api.php index 25c3438..c944d86 100644 --- a/workbench/routes/api.php +++ b/workbench/routes/api.php @@ -17,11 +17,11 @@ */ Route::patch('post/{post}', function (UpdatePostWithRouteBindingData $data) { - return response()->json($data->toArray()); + return response()->json((array) $data); })->middleware('api'); Route::patch('tags/{tag}', function (UpdateTagData $data) { return response()->json([ - 'data' => $data->toArray(), + 'data' => (array) $data, ]); })->middleware('api'); From 973968ce2ddb7a60af579a4f0887e2cd4cfab6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 22 May 2025 12:52:24 +0200 Subject: [PATCH 03/13] wip --- CHANGELOG.md | 5 + composer.json | 1 + config/data-transfer-objects.php | 8 +- src/Attributes/AsType.php | 4 +- src/Attributes/Authenticated.php | 2 +- src/Attributes/Inject.php | 6 +- src/Attributes/ModelWith.php | 2 +- src/Attributes/ResolveModel.php | 8 +- src/Attributes/Validate.php | 2 +- src/Attributes/WithDefaultValue.php | 4 +- src/Commands/DtoMakeCommand.php | 4 +- src/Contracts/DataTransferObject.php | 8 - src/Contracts/RouteTransferableObject.php | 14 ++ src/DataTransferObjects/MappingValue.php | 14 +- src/Enums/BuiltInType.php | 22 +-- src/{DynamicMapper.php => Mapper.php} | 53 +++--- .../BackedEnumPropertyMapper.php | 4 +- .../CarbonPropertyMapper.php | 8 +- .../CollectionPropertyMapper.php | 14 +- .../DataMapper.php} | 4 +- .../GenericObjectPropertyMapper.php | 6 +- .../ModelPropertyMapper.php | 21 +-- .../ObjectPropertyMapper.php | 51 +++-- src/ObjectMapper.php | 177 ------------------ src/SerializableObject.php | 42 ++--- src/ServiceProvider.php | 26 ++- src/TypeGenerator.php | 34 ++-- src/functions.php | 5 +- tests/Integration/DataTransferObjectTest.php | 52 +++-- tests/Integration/DtoMakeCommandTest.php | 6 +- .../DtoTypescriptGenerateCommandTest.php | 3 +- tests/Integration/TestCase.php | 1 + .../ValidatedDataTransferObjectTest.php | 10 +- tests/Unit/DataTransferObjectTest.php | 37 ++-- .../DataTransferObjects/CreatePostData.php | 5 +- .../UpdatePostWithRouteBindingData.php | 1 - .../UpdatePostWithTags.php | 3 +- .../app/DataTransferObjects/UpdateTagData.php | 5 +- .../2022_05_31_163139_create_tags_table.php | 1 - workbench/database/seeders/DatabaseSeeder.php | 1 - workbench/routes/api.php | 1 - 41 files changed, 248 insertions(+), 427 deletions(-) delete mode 100644 src/Contracts/DataTransferObject.php create mode 100644 src/Contracts/RouteTransferableObject.php rename src/{DynamicMapper.php => Mapper.php} (92%) rename src/{PropertyMappers => Mappers}/BackedEnumPropertyMapper.php (85%) rename src/{PropertyMappers => Mappers}/CarbonPropertyMapper.php (90%) rename src/{PropertyMappers => Mappers}/CollectionPropertyMapper.php (89%) rename src/{PropertyMappers/PropertyMapper.php => Mappers/DataMapper.php} (82%) rename src/{PropertyMappers => Mappers}/GenericObjectPropertyMapper.php (85%) rename src/{PropertyMappers => Mappers}/ModelPropertyMapper.php (92%) rename src/{PropertyMappers => Mappers}/ObjectPropertyMapper.php (88%) delete mode 100644 src/ObjectMapper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ee8e6..2c80a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Attribute `OpenSoutheners\LaravelDto\Attributes\Inject` to inject container stuff - Attribute `OpenSoutheners\LaravelDto\Attributes\Authenticated` that uses base `Illuminate\Container\Attributes\Authenticated` to inject current authenticated user - Ability to register custom mappers (extending package functionality) +- ObjectMapper now extracts type info from generics inside collections typed properties [#1] + +### Changed + +- Full refactor [#7] ### Removed diff --git a/composer.json b/composer.json index 0f36a98..4e2456f 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ }, "require-dev": { "larastan/larastan": "^3.0", + "laravel/pint": "^1.22", "orchestra/testbench": "^9.0 || ^10.0", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.0" diff --git a/config/data-transfer-objects.php b/config/data-transfer-objects.php index 7249d97..a396b2c 100644 --- a/config/data-transfer-objects.php +++ b/config/data-transfer-objects.php @@ -4,7 +4,7 @@ /** * Normalise data transfer objects property names. - * + * * For example: user_id (sent) => user (DTO) or is_published (sent) => isPublished (DTO) */ 'normalise_properties' => true, @@ -14,13 +14,13 @@ * are passed to the command. */ 'types_generation' => [ - + 'output' => null, - + 'source' => null, 'filename' => null, - + 'declarations' => false, ], diff --git a/src/Attributes/AsType.php b/src/Attributes/AsType.php index 5f980db..76f74d4 100644 --- a/src/Attributes/AsType.php +++ b/src/Attributes/AsType.php @@ -9,6 +9,6 @@ class AsType { public function __construct(public string $typeName) { - // + // } -} \ No newline at end of file +} diff --git a/src/Attributes/Authenticated.php b/src/Attributes/Authenticated.php index 1743288..8d47fba 100644 --- a/src/Attributes/Authenticated.php +++ b/src/Attributes/Authenticated.php @@ -8,5 +8,5 @@ #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)] class Authenticated extends BaseAttribute { - // + // } diff --git a/src/Attributes/Inject.php b/src/Attributes/Inject.php index e43341b..6ea7b71 100644 --- a/src/Attributes/Inject.php +++ b/src/Attributes/Inject.php @@ -11,14 +11,12 @@ class Inject implements ContextualAttribute { public function __construct(public string $value) { - // + // } - + /** * Resolve the currently authenticated user. * - * @param self $attribute - * @param \Illuminate\Contracts\Container\Container $container * @return \Illuminate\Contracts\Auth\Authenticatable|null */ public static function resolve(self $attribute, Container $container) diff --git a/src/Attributes/ModelWith.php b/src/Attributes/ModelWith.php index e377d32..f0523bd 100644 --- a/src/Attributes/ModelWith.php +++ b/src/Attributes/ModelWith.php @@ -9,6 +9,6 @@ class ModelWith { public function __construct(public string|array $relations, public ?string $type = null) { - // + // } } diff --git a/src/Attributes/ResolveModel.php b/src/Attributes/ResolveModel.php index f7423c3..0dec75b 100644 --- a/src/Attributes/ResolveModel.php +++ b/src/Attributes/ResolveModel.php @@ -13,7 +13,7 @@ final class ResolveModel { public function __construct( public string|array|null $keyFromRouteParam = null, - public string|null $morphTypeFrom = null + public ?string $morphTypeFrom = null ) { // } @@ -54,7 +54,7 @@ public function getBindingAttribute(string $key, string $type, array $with) protected function resolveBinding(string $model, mixed $value, mixed $field = null, array $with = []) { - $modelInstance = new $model(); + $modelInstance = new $model; return $modelInstance->resolveRouteBindingQuery($modelInstance, $value, $field) ->with($with); @@ -66,7 +66,7 @@ public function getMorphPropertyTypeKey(string $fromPropertyKey): string return Str::snake($this->morphTypeFrom); } - return static::getDefaultMorphKeyFrom($fromPropertyKey); + return self::getDefaultMorphKeyFrom($fromPropertyKey); } public function getMorphModel(string $fromPropertyKey, array $properties, array $propertyTypeClasses = []): array @@ -90,7 +90,7 @@ public function getMorphModel(string $fromPropertyKey, array $properties, array if (count($modelModelClass) === 0 && count($propertyTypeClasses) > 0) { $modelModelClass = array_filter( $propertyTypeClasses, - fn (string $class) => in_array((new $class())->getMorphClass(), $types) + fn (string $class) => in_array((new $class)->getMorphClass(), $types) ); $modelModelClass = reset($modelModelClass); diff --git a/src/Attributes/Validate.php b/src/Attributes/Validate.php index c6eac74..7d2a634 100644 --- a/src/Attributes/Validate.php +++ b/src/Attributes/Validate.php @@ -9,6 +9,6 @@ class Validate { public function __construct(public string $value) { - // + // } } diff --git a/src/Attributes/WithDefaultValue.php b/src/Attributes/WithDefaultValue.php index d5116b4..1ef6f72 100644 --- a/src/Attributes/WithDefaultValue.php +++ b/src/Attributes/WithDefaultValue.php @@ -9,6 +9,6 @@ class WithDefaultValue { public function __construct(public mixed $value) { - // + // } -} \ No newline at end of file +} diff --git a/src/Commands/DtoMakeCommand.php b/src/Commands/DtoMakeCommand.php index a00f656..86e05b0 100644 --- a/src/Commands/DtoMakeCommand.php +++ b/src/Commands/DtoMakeCommand.php @@ -4,9 +4,9 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use OpenSoutheners\ExtendedLaravel\Console\Concerns\OpensGeneratedFiles; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; -use OpenSoutheners\ExtendedLaravel\Console\Concerns\OpensGeneratedFiles; #[AsCommand(name: 'make:dto')] class DtoMakeCommand extends GeneratorCommand @@ -138,7 +138,7 @@ protected function getProperties(string $requestClass) return ''; } - $requestInstance = new $requestClass(); + $requestInstance = new $requestClass; $properties = ''; $requestRules = $requestInstance->rules(); diff --git a/src/Contracts/DataTransferObject.php b/src/Contracts/DataTransferObject.php deleted file mode 100644 index 5cd57dd..0000000 --- a/src/Contracts/DataTransferObject.php +++ /dev/null @@ -1,8 +0,0 @@ - */ public readonly Collection $attributes; - + /** - * @param class-string|null $objectClass - * @param array|null $types + * @param class-string|null $objectClass + * @param array|null $types */ public function __construct( public readonly mixed $data, @@ -35,9 +35,9 @@ public function __construct( public readonly ?ReflectionProperty $property = null, ) { $this->preferredType = $types ? (reset($types) ?? null) : null; - + $this->preferredTypeClass = $this->preferredType ? ($this->preferredType->getClassName() ?: $objectClass) : $objectClass; - + $this->attributes = Collection::make($class ? $class->getAttributes() : []); } } diff --git a/src/Enums/BuiltInType.php b/src/Enums/BuiltInType.php index 7911344..8f6c4ca 100644 --- a/src/Enums/BuiltInType.php +++ b/src/Enums/BuiltInType.php @@ -7,42 +7,42 @@ enum BuiltInType: string case Object = 'object'; case Array = 'array'; case Iterable = 'iterable'; - + case Integer = 'int'; case Float = 'float'; - + case String = 'string'; - + case Boolean = 'bool'; case True = 'true'; case False = 'false'; - + case Null = 'null'; case Never = 'never'; case Void = 'void'; - + case Mixed = 'mixed'; - + case Callable = 'callable'; case Resource = 'resource'; - + public static function guess($value): static { return self::tryFrom(gettype($value)) ?? self::Mixed; } - + public function assert(...$types): bool { $loops = 0; $truth = false; - + while ($truth === false && count($types) > $loops) { $type = $types[$loops]; - + $truth = $type instanceof static ? $type === $this : $type === $this->value; $loops++; } - + return $truth; } } diff --git a/src/DynamicMapper.php b/src/Mapper.php similarity index 92% rename from src/DynamicMapper.php rename to src/Mapper.php index 821d016..25f8263 100644 --- a/src/DynamicMapper.php +++ b/src/Mapper.php @@ -9,40 +9,40 @@ use ReflectionClass; use ReflectionProperty; -final class DynamicMapper +final class Mapper { protected mixed $data; - + protected ?string $dataClass = null; - + protected ?string $parentClass = null; - + protected ?string $property = null; - + protected array $propertyTypes = []; - + public function __construct(mixed $input) { if (is_object($input)) { $this->dataClass = get_class($input); } - + $this->data = $this->takeDataFrom($input); } - + protected function extractProperties(object $input): array { $reflector = new ReflectionClass($input); $extraction = []; - + foreach ($reflector->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { $property->isReadOnly(); $extraction[$property->getName()] = $property->getValue($input); } - + return $extraction; } - + protected function takeDataFrom(mixed $input): mixed { return match (true) { @@ -54,21 +54,22 @@ protected function takeDataFrom(mixed $input): mixed default => $input, }; } - + public function through(string|object $value, string $property, array $types): static { $this->parentClass = is_object($value) ? get_class($value) : $value; - + $this->property = $property; - + $this->propertyTypes = $types; - + return $this; } - + /** * @template T of object - * @param class-string $output + * + * @param class-string $output * @return T */ public function to(?string $output = null) @@ -77,25 +78,25 @@ public function to(?string $output = null) // if ($output && is_a($output, Model::class, true)) { // /** @var Model $model */ // $model = new $output; - + // foreach ($this->data as $key => $value) { // if ($model->isRelation($key) && $model->$key() instanceof BelongsTo) { // $model->$key()->associate($value); // } - + // $model->fill([$key => $value]); // } - + // return $model; // } - + $output ??= $this->dataClass; - + $mappedValue = $this->data; - + $reflectionClass = $this->parentClass || $output ? new ReflectionClass($this->parentClass ?: $output) : null; $reflectionProperty = $reflectionClass && $this->property ? $reflectionClass->getProperty($this->property) : null; - + $mappingDataValue = new MappingValue( data: $this->data, typeFromData: BuiltInType::guess($this->data), @@ -104,7 +105,7 @@ public function to(?string $output = null) class: $reflectionClass, property: $reflectionProperty, ); - + foreach (ServiceProvider::getMappers() as $mapper) { if ($mapper->assert($mappingDataValue)) { $mappedValue = $mapper->resolve($mappingDataValue); @@ -112,7 +113,7 @@ class: $reflectionClass, break; } } - + return $mappedValue; } } diff --git a/src/PropertyMappers/BackedEnumPropertyMapper.php b/src/Mappers/BackedEnumPropertyMapper.php similarity index 85% rename from src/PropertyMappers/BackedEnumPropertyMapper.php rename to src/Mappers/BackedEnumPropertyMapper.php index 2a4128b..8e0b756 100644 --- a/src/PropertyMappers/BackedEnumPropertyMapper.php +++ b/src/Mappers/BackedEnumPropertyMapper.php @@ -1,11 +1,11 @@ preferredTypeClass === CarbonInterface::class || is_subclass_of($mappingValue->preferredTypeClass, CarbonInterface::class)); } - + /** * Resolve mapper that runs once assert returns true. */ @@ -29,7 +29,7 @@ public function resolve(MappingValue $mappingValue): mixed $mappingValue->typeFromData === BuiltInType::Integer || is_numeric($mappingValue->data) => Carbon::createFromTimestamp($mappingValue->data), default => Carbon::make($mappingValue->data), }; - + if ($mappingValue->preferredType === CarbonImmutable::class) { $dateValue->toImmutable(); } diff --git a/src/PropertyMappers/CollectionPropertyMapper.php b/src/Mappers/CollectionPropertyMapper.php similarity index 89% rename from src/PropertyMappers/CollectionPropertyMapper.php rename to src/Mappers/CollectionPropertyMapper.php index a60c4e4..ffd45f9 100644 --- a/src/PropertyMappers/CollectionPropertyMapper.php +++ b/src/Mappers/CollectionPropertyMapper.php @@ -1,6 +1,6 @@ preferredTypeClass === Collection::class || $mappingValue->preferredTypeClass === EloquentCollection::class; } - + /** * Resolve mapper that runs once assert returns true. * - * @param string[]|string $types - * @param Collection<\ReflectionAttribute> $attributes - * @param array $properties + * @param string[]|string $types + * @param Collection<\ReflectionAttribute> $attributes + * @param array $properties */ public function resolve(MappingValue $mappingValue): mixed { @@ -63,7 +63,7 @@ public function resolve(MappingValue $mappingValue): mixed $preferredCollectionTypeClass = $preferredCollectionType ? $preferredCollectionType->getClassName() : null; $collection = $collection->map(fn ($value) => is_string($value) ? trim($value) : $value) - ->filter(fn($item) => !blank($item)); + ->filter(fn ($item) => ! blank($item)); if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { diff --git a/src/PropertyMappers/PropertyMapper.php b/src/Mappers/DataMapper.php similarity index 82% rename from src/PropertyMappers/PropertyMapper.php rename to src/Mappers/DataMapper.php index 109b092..4aa994c 100644 --- a/src/PropertyMappers/PropertyMapper.php +++ b/src/Mappers/DataMapper.php @@ -1,10 +1,10 @@ data)) { return (object) $mappingValue->data; } - + return json_decode($mappingValue->data); } } diff --git a/src/PropertyMappers/ModelPropertyMapper.php b/src/Mappers/ModelPropertyMapper.php similarity index 92% rename from src/PropertyMappers/ModelPropertyMapper.php rename to src/Mappers/ModelPropertyMapper.php index 093806a..793c243 100644 --- a/src/PropertyMappers/ModelPropertyMapper.php +++ b/src/Mappers/ModelPropertyMapper.php @@ -1,6 +1,6 @@ preferredTypeClass === Model::class || is_subclass_of($mappingValue->preferredTypeClass, Model::class); } - + /** * Resolve mapper that runs once assert returns true. */ public function resolve(MappingValue $mappingValue): mixed { $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); - + /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); @@ -56,7 +56,7 @@ public function resolve(MappingValue $mappingValue): mixed if (is_array($modelType) && $mappingValue->objectClass === Collection::class) { $valueClass = get_class($mappingValue->data); - + $modelType = $modelClass[array_search($valueClass, $modelClass)]; } @@ -83,7 +83,7 @@ public function resolve(MappingValue $mappingValue): mixed return Collection::make(array_map( function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { - if (!is_null($valueB)) { + if (! is_null($valueB)) { $lastNonValue = $valueB; } @@ -91,11 +91,10 @@ function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { }, $mappingValue->data instanceof Collection ? $mappingValue->data->all() : (array) $mappingValue->data, (array) $modelType - ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => - $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute) + ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute) ); } - + /** * Get model instance(s) for model class and given IDs. * @@ -129,11 +128,11 @@ protected function getModelInstance(string $model, mixed $id, mixed $usingAttrib return $baseQuery->first(); } - + /** * Resolve model class strings and keys into instances. * - * @param array $withAttributes + * @param array $withAttributes */ protected function resolveIntoModelInstance(mixed $keys, string $modelClass, string $propertyKey, array $withAttributes = [], ?ResolveModel $bindingAttribute = null): mixed { diff --git a/src/PropertyMappers/ObjectPropertyMapper.php b/src/Mappers/ObjectPropertyMapper.php similarity index 88% rename from src/PropertyMappers/ObjectPropertyMapper.php rename to src/Mappers/ObjectPropertyMapper.php index 9bdd60c..d67e1df 100644 --- a/src/PropertyMappers/ObjectPropertyMapper.php +++ b/src/Mappers/ObjectPropertyMapper.php @@ -1,17 +1,16 @@ preferredTypeClass + ! $mappingValue->preferredTypeClass || $mappingValue->preferredTypeClass === stdClass::class - || !class_exists($mappingValue->preferredTypeClass) - || !(new ReflectionClass($mappingValue->preferredTypeClass))->isInstantiable() + || ! class_exists($mappingValue->preferredTypeClass) + || ! (new ReflectionClass($mappingValue->preferredTypeClass))->isInstantiable() ) { return false; } - + return is_array($mappingValue->data) && (is_string(array_key_first($mappingValue->data)) || is_json_structure($mappingValue->data)); } @@ -46,10 +45,10 @@ public function assert(MappingValue $mappingValue): bool public function resolve(MappingValue $mappingValue): mixed { $data = []; - $propertiesInfo = static::getPropertiesInfoFrom($mappingValue->preferredTypeClass); - + $propertiesInfo = self::getPropertiesInfoFrom($mappingValue->preferredTypeClass); + $mappingData = is_string($mappingValue->data) ? json_decode($mappingValue->data, true) : $mappingValue->data; - + $propertiesData = array_combine( array_map(fn ($key) => $this->normalisePropertyKey($mappingValue, $key), array_keys($mappingData)), array_values($mappingData) @@ -72,10 +71,10 @@ public function resolve(MappingValue $mappingValue): mixed $containerAttribute = $propertyAttributes->filter( fn (ReflectionAttribute $attribute) => is_subclass_of($attribute->getName(), ContextualAttribute::class) )->first(); - + if ($containerAttribute) { $data[$key] = app()->resolveFromAttribute($containerAttribute); - + continue; } @@ -86,7 +85,7 @@ public function resolve(MappingValue $mappingValue): mixed $preferredType = reset($propertyTypes); $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); $preferredTypeClass = $preferredType->getClassName(); - + if ( $preferredTypeClass && ! is_array($value) @@ -100,15 +99,15 @@ public function resolve(MappingValue $mappingValue): mixed continue; } - + $data[$key] = map($value) ->through($mappingValue->preferredTypeClass, $key, $propertyTypes) ->to($preferredTypeClass); } - + return new $mappingValue->preferredTypeClass(...$data); } - + /** * Normalise property key using camel case or original. */ @@ -133,7 +132,7 @@ protected function normalisePropertyKey(MappingValue $mappingValue, string $key) default => null }; } - + /** * Get instance of property info extractor. * @@ -141,24 +140,24 @@ protected function normalisePropertyKey(MappingValue $mappingValue, string $key) */ public static function getPropertiesInfoFrom(string $class, ?string $property = null): array { - $phpStanExtractor = new PhpStanExtractor(); - $reflectionExtractor = new ReflectionExtractor(); + $phpStanExtractor = new PhpStanExtractor; + $reflectionExtractor = new ReflectionExtractor; $extractor = new PropertyInfoExtractor( [$reflectionExtractor], [$phpStanExtractor, $reflectionExtractor], ); - + if ($property) { return [$property => $extractor->getTypes($class, $property) ?? []]; } - + $propertiesInfo = []; - + foreach ($extractor->getProperties($class) as $key) { $propertiesInfo[$key] = $extractor->getTypes($class, $key) ?? []; } - + return $propertiesInfo; } } diff --git a/src/ObjectMapper.php b/src/ObjectMapper.php deleted file mode 100644 index 43b4441..0000000 --- a/src/ObjectMapper.php +++ /dev/null @@ -1,177 +0,0 @@ - - */ - protected static array $mappers = []; - - /** - * Get instance of property info extractor. - */ - public static function registerMapper(array|PropertyMapper $mappers) - { - static::$mappers = array_merge(static::$mappers, $mappers); - } - - /** - * Get instance of property info extractor. - * - * @return array> - */ - public static function getPropertiesInfoFrom(string $class, ?string $property = null): array - { - $phpStanExtractor = new PhpStanExtractor(); - $reflectionExtractor = new ReflectionExtractor(); - - $extractor = new PropertyInfoExtractor( - [$reflectionExtractor], - [$phpStanExtractor, $reflectionExtractor], - ); - - if ($property) { - return [$property => $extractor->getTypes($class, $property) ?? []]; - } - - $propertiesInfo = []; - - foreach ($extractor->getProperties($class) as $key) { - $propertiesInfo[$key] = $extractor->getTypes($class, $key) ?? []; - } - - return $propertiesInfo; - } - - public function __construct( - protected array $properties, - protected string $dataClass, - protected array $data = [] - ) { - $this->reflector = new ReflectionClass($this->dataClass); - } - - /** - * Run properties mapper through all sent class properties. - */ - public function run(): array - { - $propertiesInfo = static::getPropertiesInfoFrom($this->dataClass); - - $propertiesData = array_combine( - array_map(fn ($key) => $this->normalisePropertyKey($key), array_keys($this->properties)), - array_values($this->properties) - ); - - foreach ($propertiesInfo as $key => $propertyTypes) { - $value = $propertiesData[$key] ?? null; - - if (count($propertyTypes) === 0) { - $this->data[$key] = $value; - - continue; - } - - /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ - $propertyAttributes = Collection::make( - $this->reflector->getProperty($key)->getAttributes() - ); - - $containerAttribute = $propertyAttributes->filter( - fn (ReflectionAttribute $attribute) => is_subclass_of($attribute->getName(), ContextualAttribute::class) - )->first(); - - if ($containerAttribute) { - $this->data[$key] = app()->resolveFromAttribute($containerAttribute); - - continue; - } - - if (is_null($value)) { - continue; - } - - $preferredType = reset($propertyTypes); - $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); - $preferredTypeClass = $preferredType->getClassName(); - - if ( - count(static::$mappers) === 0 - || ($preferredTypeClass - && ! is_array($value) - && ! $preferredType->isCollection() - && $preferredTypeClass !== Collection::class - && ! is_a($preferredTypeClass, Model::class, true) - && (is_a($value, $preferredTypeClass, true) - || (is_object($value) && in_array(get_class($value), $propertyTypesClasses)))) - ) { - $this->data[$key] = $value; - - continue; - } - - $this->data[$key] = map($value)->to($preferredTypeClass); - } - - return $this->data; - } - - public function mapFromType(string $key, Type $preferredType, mixed $value, array $types, Collection $attributes): mixed - { - $mappedValue = $value; - - foreach (static::$mappers as $mapper) { - if ($mapper->assert($preferredType, $value)) { - $mappedValue = $mapper->resolve($types, $key, $value, $attributes, $this->properties); - - break; - } - } - - return $mappedValue; - } - - /** - * Normalise property key using camel case or original. - */ - protected function normalisePropertyKey(string $key): ?string - { - $normaliseProperty = count($this->reflector->getAttributes(NormaliseProperties::class)) > 0 - ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); - - if (! $normaliseProperty) { - return $key; - } - - if (Str::endsWith($key, '_id')) { - $key = Str::replaceLast('_id', '', $key); - } - - $camelKey = Str::camel($key); - - return match (true) { - property_exists($this->dataClass, $key) => $key, - property_exists($this->dataClass, $camelKey) => $camelKey, - default => null - }; - } -} diff --git a/src/SerializableObject.php b/src/SerializableObject.php index 8cee7fa..436c50b 100644 --- a/src/SerializableObject.php +++ b/src/SerializableObject.php @@ -76,7 +76,7 @@ public function filled(string $property): bool return true; } - + /** * Get the instance as an array. * @@ -87,51 +87,51 @@ public function toArray() /** @var array<\ReflectionProperty> $properties */ $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); $newPropertiesArr = []; - + foreach ($properties as $property) { if (! $this->filled($property->name) && count($property->getAttributes(WithDefaultValue::class)) === 0) { continue; } - + $propertyValue = $property->getValue($this) ?? $property->getDefaultValue(); - + if ($propertyValue instanceof Arrayable) { $propertyValue = $propertyValue->toArray(); } - + if ($propertyValue instanceof \stdClass) { $propertyValue = (array) $propertyValue; } - + $newPropertiesArr[Str::snake($property->name)] = $propertyValue; } - + return $newPropertiesArr; } - + public function __serialize(): array { $reflection = new \ReflectionClass($this); - + /** @var array<\ReflectionProperty> $properties */ $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); - + $serialisableArr = []; - + foreach ($properties as $property) { $key = $property->getName(); $value = $property->getValue($this); - + /** @var array<\ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>> $propertyModelBindingAttribute */ $propertyModelBindingAttribute = $property->getAttributes(BindModel::class); $propertyModelBindingAttribute = reset($propertyModelBindingAttribute); - + $propertyModelBindingAttributeName = null; - + if ($propertyModelBindingAttribute) { $propertyModelBindingAttributeName = $propertyModelBindingAttribute->newInstance()->using; } - + $serialisableArr[$key] = match (true) { $value instanceof Model => $value->getAttribute($propertyModelBindingAttributeName ?? $value->getRouteKeyName()), $value instanceof Collection => $value->first() instanceof Model ? $value->map(fn (Model $model) => $model->getAttribute($propertyModelBindingAttributeName ?? $model->getRouteKeyName()))->join(',') : $value->join(','), @@ -141,24 +141,24 @@ public function __serialize(): array default => $value, }; } - + return $serialisableArr; } - + /** * Called during unserialization of the object. */ public function __unserialize(array $data): void { $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); - + $propertiesMapper = new ObjectMapper(array_merge($data), static::class); - + $data = $propertiesMapper->run(); - + foreach ($properties as $property) { $key = $property->getName(); - + $this->{$key} = $data[$key] ?? $property->getDefaultValue(); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index d51b451..63cc28c 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,14 +2,12 @@ namespace OpenSoutheners\LaravelDto; -use Exception; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use OpenSoutheners\LaravelDto\Attributes\Validate; use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; use OpenSoutheners\LaravelDto\Commands\DtoTypescriptGenerateCommand; use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; -use OpenSoutheners\LaravelDto\PropertyMappers; use OpenSoutheners\LaravelDto\PropertyMappers\PropertyMapper; use ReflectionClass; @@ -18,14 +16,14 @@ class ServiceProvider extends BaseServiceProvider protected static $mappers = [ PropertyMappers\CollectionPropertyMapper::class, PropertyMappers\ModelPropertyMapper::class, - + PropertyMappers\CarbonPropertyMapper::class, PropertyMappers\BackedEnumPropertyMapper::class, PropertyMappers\GenericObjectPropertyMapper::class, PropertyMappers\ObjectPropertyMapper::class, - + ]; - + /** * Bootstrap any application services. * @@ -40,17 +38,17 @@ public function boot() $this->commands([DtoMakeCommand::class, DtoTypescriptGenerateCommand::class]); } - + $this->app->beforeResolving( DataTransferObject::class, function ($dataClass, $parameters, $app) { /** @var \Illuminate\Foundation\Application $app */ $app->scoped($dataClass, function () use ($dataClass, $app) { $reflector = new ReflectionClass($dataClass); - + $validateAttributes = $reflector->getAttributes(Validate::class); $validateAttribute = reset($validateAttributes); - + return map( $app->make($validateAttribute ? $validateAttribute->newInstance()->value : Request::class) )->to($dataClass); @@ -68,17 +66,17 @@ public function register() { // } - + /** * Register new dynamic mappers. */ public function registerMapper(string|array $mapper, bool $replacing = false): void { $mappers = (array) $mapper; - + static::$mappers = $replacing ? $mappers : array_merge(static::$mappers, $mapper); } - + /** * Get dynamic mappers. * @@ -87,15 +85,15 @@ public function registerMapper(string|array $mapper, bool $replacing = false): v public static function getMappers(): array { $mappers = []; - + foreach (static::$mappers as $mapper) { $mapperInstance = new $mapper; - + if ($mapperInstance instanceof PropertyMapper) { $mappers[] = $mapperInstance; } } - + return $mappers; } } diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index 7e2fb00..e85c50b 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -16,7 +16,7 @@ class TypeGenerator { /** * Map native types from PHP to TypeScript. - * + * * @var array */ public const PHP_TO_TYPESCRIPT_VARIANT_TYPES = [ @@ -30,7 +30,7 @@ public function __construct( protected string $dataTransferObject, protected Collection $generatedTypes ) { - // + // } /** @@ -40,16 +40,16 @@ public function generate(): void { $reflection = new ReflectionClass($this->dataTransferObject); - /** + /** * Only needed when non-typed properties are found to compare with isOptional * on the parameter reflector. - * + * * @var array<\ReflectionParameter> $constructorParameters */ $constructorParameters = $reflection->getConstructor() ? $reflection->getConstructor()->getParameters() : []; $normalisesPropertiesKeys = config('data-transfer-objects.normalise_properties', true); - + if (! empty($reflection->getAttributes(NormaliseProperties::class))) { $normalisesPropertiesKeys = true; } @@ -58,7 +58,7 @@ public function generate(): void $exportedType = $this->getExportTypeName($reflection); $exportAsString = "export type {$exportedType} = {\n"; - + foreach ($properties as $propertyName => $propertyTypes) { $propertyType = reset($propertyTypes); @@ -84,15 +84,15 @@ public function generate(): void $exportAsString .= "\t{$propertyKeyAsString}{$nullMark}: {$propertyTypeAsString};\n"; } - $exportAsString .= "};"; + $exportAsString .= '};'; $this->generatedTypes[$exportedType] = $exportAsString; } /** * Determine whether the specified property is nullable. - * - * @param array<\ReflectionParameter> $constructorParameters + * + * @param array<\ReflectionParameter> $constructorParameters */ protected function isNullableProperty(Type|false $propertyType, string $propertyName, array $constructorParameters): bool { @@ -172,7 +172,7 @@ protected function extractObjectType(string $objectClass): string /** * Generate types from PHP native enum. - * + * * @see https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums */ protected function extractEnumType(string $enumClass): string @@ -182,16 +182,16 @@ protected function extractEnumType(string $enumClass): string if ($this->generatedTypes->has($exportedType)) { return $exportedType; } - + $exportsAsString = ''; $exportsAsString .= "export const enum {$exportedType} {\n"; - + foreach ($enumClass::cases() as $case) { $caseValueAsString = is_int($case->value) ? $case->value : "\"{$case->value}\""; $exportsAsString .= "\t{$case->name} = {$caseValueAsString},\n"; } - $exportsAsString .= "};"; + $exportsAsString .= '};'; $this->generatedTypes[$exportedType] = $exportsAsString; @@ -200,8 +200,8 @@ protected function extractEnumType(string $enumClass): string /** * Generate types from collection. - * - * @param array<\Symfony\Component\PropertyInfo\Type> $collectedTypes + * + * @param array<\Symfony\Component\PropertyInfo\Type> $collectedTypes */ protected function extractCollectionType(string $collection, array $collectedTypes): string { @@ -216,8 +216,8 @@ protected function extractCollectionType(string $collection, array $collectedTyp /** * Generate types from Eloquent models bindings. - * - * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass */ protected function extractModelType(string $modelClass): string { diff --git a/src/functions.php b/src/functions.php index c642534..70a8764 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,6 +2,7 @@ namespace OpenSoutheners\LaravelDto; -function map(mixed $input): DynamicMapper { - return new DynamicMapper($input); +function map(mixed $input): Mapper +{ + return new Mapper($input); } diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/Integration/DataTransferObjectTest.php index 2c4e8f2..598eafb 100644 --- a/tests/Integration/DataTransferObjectTest.php +++ b/tests/Integration/DataTransferObjectTest.php @@ -5,7 +5,6 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Route; use Illuminate\Validation\Rule; use Mockery; @@ -14,7 +13,6 @@ use Workbench\App\DataTransferObjects\UpdatePostData; use Workbench\App\DataTransferObjects\UpdatePostWithDefaultData; use Workbench\App\Enums\PostStatus; -use Workbench\App\Models\Film; use Workbench\App\Models\Post; use Workbench\App\Models\User; use Workbench\Database\Factories\FilmFactory; @@ -25,9 +23,9 @@ class DataTransferObjectTest extends TestCase { - public function testDataTransferObjectFromRequest() + public function test_data_transfer_object_from_request() { - $user = (new User())->forceFill([ + $user = (new User)->forceFill([ 'id' => 1, 'name' => 'Ruben', 'email' => 'ruben@hello.com', @@ -65,7 +63,7 @@ public function testDataTransferObjectFromRequest() $this->assertTrue($user->is($data->currentUser)); } - public function testDataTransferObjectFromArrayWithModels() + public function test_data_transfer_object_from_array_with_models() { $post = Post::create([ 'id' => 1, @@ -88,10 +86,10 @@ public function testDataTransferObjectFromArrayWithModels() $this->assertTrue($data->post->is($post)); } - public function testDataTransferObjectFilledViaRequest() + public function test_data_transfer_object_filled_via_request() { $this->markTestSkipped('Need to reimplement filled method as a trait'); - + /** @var CreatePostFormRequest */ $mock = Mockery::mock(app(Request::class))->makePartial(); @@ -114,7 +112,7 @@ public function testDataTransferObjectFilledViaRequest() $this->assertFalse($data->filled('post')); } - public function testDataTransferObjectWithoutPropertyKeysNormalisationWhenDisabledFromConfig() + public function test_data_transfer_object_without_property_keys_normalisation_when_disabled_from_config() { config(['data-transfer-objects.normalise_properties' => false]); @@ -142,10 +140,10 @@ public function testDataTransferObjectWithoutPropertyKeysNormalisationWhenDisabl $this->assertTrue($data->parent?->is($parentPost)); } - public function testDataTransferObjectWithDefaultValueAttribute() + public function test_data_transfer_object_with_default_value_attribute() { $this->markTestSkipped('To implement default values'); - + $user = User::create([ 'email' => 'ruben@hello.com', 'password' => '1234', @@ -176,7 +174,7 @@ public function testDataTransferObjectWithDefaultValueAttribute() ]); } - public function testDataTransferObjectWithDefaultValueAttributeGetsBoundWhenOneIsSent() + public function test_data_transfer_object_with_default_value_attribute_gets_bound_when_one_is_sent() { $user = User::create([ 'email' => 'ruben@hello.com', @@ -208,7 +206,7 @@ public function testDataTransferObjectWithDefaultValueAttributeGetsBoundWhenOneI ]); } - public function testDataTransferObjectWithMorphsGetsModelsBoundOfEachTypeSent() + public function test_data_transfer_object_with_morphs_gets_models_bound_of_each_type_sent() { $user = User::create([ 'email' => 'ruben@hello.com', @@ -236,7 +234,7 @@ public function testDataTransferObjectWithMorphsGetsModelsBoundOfEachTypeSent() $myFilm = FilmFactory::new()->create([ 'title' => 'My Film', 'slug' => 'my-film', - 'year' => 1997 + 'year' => 1997, ]); $response = $this->patchJson('tags/1', [ @@ -252,33 +250,33 @@ public function testDataTransferObjectWithMorphsGetsModelsBoundOfEachTypeSent() $response->assertJsonCount(3, 'data.taggable'); $response->assertJsonFragment([ - "id" => 1, - "title" => "My Film", - "year" => "1997", - "about" => null + 'id' => 1, + 'title' => 'My Film', + 'year' => '1997', + 'about' => null, ]); $response->assertJsonFragment([ - "id" => 1, - "title" => "Foo bar", - "slug" => "foo-bar", - "status" => "published" + 'id' => 1, + 'title' => 'Foo bar', + 'slug' => 'foo-bar', + 'status' => 'published', ]); $response->assertJsonFragment([ - "id" => 2, - "title" => "Hello world", - "slug" => "hello-world", - "status" => "published" + 'id' => 2, + 'title' => 'Hello world', + 'slug' => 'hello-world', + 'status' => 'published', ]); } - public function testNestedDataTransferObjectsGetsTheNestedAsObjectInstance() + public function test_nested_data_transfer_objects_gets_the_nested_as_object_instance() { $this->markTestIncomplete('Need to create nested actions/DTOs'); } - public function testDataTransferObjectDoesNotTakeRouteBoundStuff() + public function test_data_transfer_object_does_not_take_route_bound_stuff() { $this->markTestIncomplete('Need to create nested actions/DTOs'); } diff --git a/tests/Integration/DtoMakeCommandTest.php b/tests/Integration/DtoMakeCommandTest.php index 1909b7b..1cc884f 100644 --- a/tests/Integration/DtoMakeCommandTest.php +++ b/tests/Integration/DtoMakeCommandTest.php @@ -12,7 +12,7 @@ class DtoMakeCommandTest extends TestCase 'app/DataTransferObjects/CreatePostData.php', ]; - public function testMakeDataTransferObjectCommandCreatesBasicClassWithName() + public function test_make_data_transfer_object_command_creates_basic_class_with_name() { $this->artisan('make:dto', ['name' => 'CreatePostData']) ->assertExitCode(0); @@ -23,7 +23,7 @@ public function testMakeDataTransferObjectCommandCreatesBasicClassWithName() ], 'app/DataTransferObjects/CreatePostData.php'); } - public function testMakeDataTransferObjectCommandWithEmptyRequestOptionCreatesTheFileWithValidatedRequest() + public function test_make_data_transfer_object_command_with_empty_request_option_creates_the_file_with_validated_request() { $this->artisan('make:dto', [ 'name' => 'CreatePostData', @@ -39,7 +39,7 @@ public function testMakeDataTransferObjectCommandWithEmptyRequestOptionCreatesTh } // TODO: Test properties from rules population - public function testMakeDataTransferObjectCommandWithRequestOptionCreatesTheFileWithProperties() + public function test_make_data_transfer_object_command_with_request_option_creates_the_file_with_properties() { $this->artisan('make:dto', [ 'name' => 'CreatePostData', diff --git a/tests/Integration/DtoTypescriptGenerateCommandTest.php b/tests/Integration/DtoTypescriptGenerateCommandTest.php index 8e00169..3fd7074 100644 --- a/tests/Integration/DtoTypescriptGenerateCommandTest.php +++ b/tests/Integration/DtoTypescriptGenerateCommandTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\App; use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles; + use function Orchestra\Testbench\workbench_path; class DtoTypescriptGenerateCommandTest extends TestCase @@ -28,7 +29,7 @@ protected function setUp(): void ]); } - public function testDtoTypescriptGeneratesTypescriptTypesFile() + public function test_dto_typescript_generates_typescript_types_file() { App::shouldReceive('getNamespace') ->once() diff --git a/tests/Integration/TestCase.php b/tests/Integration/TestCase.php index 8d77350..074ff8a 100644 --- a/tests/Integration/TestCase.php +++ b/tests/Integration/TestCase.php @@ -3,6 +3,7 @@ namespace OpenSoutheners\LaravelDto\Tests\Integration; use Orchestra\Testbench\Concerns\WithWorkbench; + use function Orchestra\Testbench\workbench_path; class TestCase extends \Orchestra\Testbench\TestCase diff --git a/tests/Integration/ValidatedDataTransferObjectTest.php b/tests/Integration/ValidatedDataTransferObjectTest.php index 5dda653..9cd0bac 100644 --- a/tests/Integration/ValidatedDataTransferObjectTest.php +++ b/tests/Integration/ValidatedDataTransferObjectTest.php @@ -21,7 +21,7 @@ protected function setUp(): void $this->withoutExceptionHandling(); } - public function testValidatedDataTransferObjectGetsRouteBoundModel() + public function test_validated_data_transfer_object_gets_route_bound_model() { $post = PostFactory::new()->hasAttached( TagFactory::new()->count(2) @@ -34,7 +34,7 @@ public function testValidatedDataTransferObjectGetsRouteBoundModel() ], true); } - public function testValidatedDataTransferObjectGetsValidatedOnlyParameters() + public function test_validated_data_transfer_object_gets_validated_only_parameters() { PostFactory::new()->create(); @@ -64,7 +64,7 @@ public function testValidatedDataTransferObjectGetsValidatedOnlyParameters() ], true); } - public function testDataTransferObjectWithModelSentDoesLoadRelationshipIfMissing() + public function test_data_transfer_object_with_model_sent_does_load_relationship_if_missing() { $post = PostFactory::new()->hasAttached( TagFactory::new()->count(2) @@ -82,7 +82,7 @@ public function testDataTransferObjectWithModelSentDoesLoadRelationshipIfMissing $this->assertEmpty(DB::getQueryLog()); } - public function testDataTransferObjectWithModelSentDoesNotRunQueriesToFetchItAgain() + public function test_data_transfer_object_with_model_sent_does_not_run_queries_to_fetch_it_again() { $post = PostFactory::new()->make(); @@ -98,7 +98,7 @@ public function testDataTransferObjectWithModelSentDoesNotRunQueriesToFetchItAga $this->assertTrue($data->post->is($post)); } - public function testDataTransferObjectCanBeSerializedAndDeserialized() + public function test_data_transfer_object_can_be_serialized_and_deserialized() { $this->withoutExceptionHandling(); diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index c2a2475..f06dbca 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -9,7 +9,6 @@ use Illuminate\Support\Collection; use Mockery; use OpenSoutheners\LaravelDto\ObjectMapper; -use OpenSoutheners\LaravelDto\PropertyMappers; use PHPUnit\Framework\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; @@ -20,10 +19,10 @@ class DataTransferObjectTest extends TestCase { - public function setUp(): void + protected function setUp(): void { parent::setUp(); - + ObjectMapper::registerMapper([ new PropertyMappers\ModelPropertyMapper, new PropertyMappers\CollectionPropertyMapper, @@ -49,7 +48,7 @@ public function setUp(): void Container::getInstance()->bind('dto.context.booted', fn () => ''); } - public function testDataTransferObjectFromArray() + public function test_data_transfer_object_from_array() { $data = map([ 'title' => 'Hello world', @@ -64,7 +63,7 @@ public function testDataTransferObjectFromArray() $this->assertNull($data->post); } - public function testDataTransferObjectFromArrayDelimitedLists() + public function test_data_transfer_object_from_array_delimited_lists() { $data = map([ 'title' => 'Hello world', @@ -77,10 +76,10 @@ public function testDataTransferObjectFromArrayDelimitedLists() $this->assertIsString($data->country); } - public function testDataTransferObjectFilledViaClassProperties() + public function test_data_transfer_object_filled_via_class_properties() { $this->markTestSkipped('To implement filled method as trait'); - + $data = map([ 'title' => 'Hello world', 'tags' => '', @@ -95,7 +94,7 @@ public function testDataTransferObjectFilledViaClassProperties() $this->assertFalse($data->filled('author_email')); } - public function testDataTransferObjectWithDefaults() + public function test_data_transfer_object_with_defaults() { $data = map([ 'title' => 'Hello world', @@ -107,7 +106,7 @@ public function testDataTransferObjectWithDefaults() $this->assertContains('post', $data->tags); } - public function testDataTransferObjectArrayWithoutTypedPropertiesGetsThroughWithoutChanges() + public function test_data_transfer_object_array_without_typed_properties_gets_through_without_changes() { $helloTag = [ 'name' => 'Hello world', @@ -132,7 +131,7 @@ public function testDataTransferObjectArrayWithoutTypedPropertiesGetsThroughWith $this->assertContains($travelingTag, $data->tags); } - public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() + public function test_data_transfer_object_array_properties_gets_mapped_as_collection() { $rubenUser = [ 'name' => 'Rubén Robles', @@ -159,7 +158,7 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() $this->assertContains($taylorUser, $data->subscribers); } - public function testDataTransferObjectDatePropertiesGetMappedFromStringsIntoCarbonInstances() + public function test_data_transfer_object_date_properties_get_mapped_from_strings_into_carbon_instances() { $data = map([ 'title' => 'Hello world', @@ -173,7 +172,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromStringsIntoCarb $this->assertTrue(now()->isAfter($data->publishedAt)); } - public function testDataTransferObjectDatePropertiesGetMappedFromJsonStringsIntoGenericObjects() + public function test_data_transfer_object_date_properties_get_mapped_from_json_strings_into_generic_objects() { $data = map([ 'title' => 'Hello world', @@ -186,7 +185,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromJsonStringsInto $this->assertObjectHasProperty('type', $data->content); } - public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGenericObjects() + public function test_data_transfer_object_date_properties_get_mapped_from_arrays_into_generic_objects() { $data = map([ 'title' => 'Hello world', @@ -215,7 +214,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGener $this->assertObjectHasProperty('type', $data->content); } - public function testDataTransferObjectDatePropertiesGetMappedFromArraysOfObjectsIntoCollectionOfGenericObjects() + public function test_data_transfer_object_date_properties_get_mapped_from_arrays_of_objects_into_collection_of_generic_objects() { $data = map([ 'title' => 'Hello world', @@ -233,7 +232,7 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysOfObjects $this->assertTrue(now()->isAfter($data->dates->last())); } - public function testDataTransferObjectDatePropertiesDoesNotGetMappedFromCollectionsToSameType() + public function test_data_transfer_object_date_properties_does_not_get_mapped_from_collections_to_same_type() { $data = map([ 'title' => 'Hello world', @@ -250,7 +249,7 @@ public function testDataTransferObjectDatePropertiesDoesNotGetMappedFromCollecti $this->assertIsString($data->dates->first()); } - public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFromArray() + public function test_data_transfer_object_sent_into_another_as_collected_will_be_mapped_from_array() { $data = map([ 'posts' => [ @@ -288,14 +287,14 @@ public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFrom $this->assertInstanceOf(Carbon::class, $data->posts->last()->dates->first()); } - public function testDataTransferObjectRetainKeysFromNestedObjectsOrArrays() + public function test_data_transfer_object_retain_keys_from_nested_objects_or_arrays() { $data = map([ 'content' => 'hello world', 'tags' => [ 'hello' => 'world', - 'foo' => 'bar' - ] + 'foo' => 'bar', + ], ])->to(CreateComment::class); $this->assertArrayHasKey('hello', $data->tags); diff --git a/workbench/app/DataTransferObjects/CreatePostData.php b/workbench/app/DataTransferObjects/CreatePostData.php index fef2d12..f2885eb 100644 --- a/workbench/app/DataTransferObjects/CreatePostData.php +++ b/workbench/app/DataTransferObjects/CreatePostData.php @@ -2,7 +2,6 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\Authenticated; @@ -21,7 +20,7 @@ class CreatePostData implements DataTransferObject */ public function __construct( public string $title, - public array|null $tags, + public ?array $tags, public PostStatus $postStatus, public ?Post $post = null, public array|string|null $country = null, @@ -35,7 +34,7 @@ public function __construct( $authorEmail = null ) { $this->tags ??= ['generic', 'post']; - + $this->authorEmail = $authorEmail; } } diff --git a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php index 27f4b8a..080a1b1 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php @@ -3,7 +3,6 @@ namespace Workbench\App\DataTransferObjects; use Carbon\CarbonImmutable; -use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\AsType; use OpenSoutheners\LaravelDto\Attributes\Authenticated; diff --git a/workbench/app/DataTransferObjects/UpdatePostWithTags.php b/workbench/app/DataTransferObjects/UpdatePostWithTags.php index a631745..0afd1f9 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithTags.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithTags.php @@ -12,6 +12,5 @@ public function __construct( public Post $post, public string $title, public Collection $tags - ) { - } + ) {} } diff --git a/workbench/app/DataTransferObjects/UpdateTagData.php b/workbench/app/DataTransferObjects/UpdateTagData.php index ad5c912..887e155 100644 --- a/workbench/app/DataTransferObjects/UpdateTagData.php +++ b/workbench/app/DataTransferObjects/UpdateTagData.php @@ -2,13 +2,10 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\Authenticated; -use OpenSoutheners\LaravelDto\Attributes\BindModel; use OpenSoutheners\LaravelDto\Attributes\ResolveModel; use OpenSoutheners\LaravelDto\Attributes\Validate; -use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Http\Requests\TagUpdateFormRequest; use Workbench\App\Models\Film; @@ -20,7 +17,7 @@ class UpdateTagData implements DataTransferObject { /** - * @param \Illuminate\Support\Collection<\Workbench\App\Models\Post|\Workbench\App\Models\Film> $taggable + * @param \Illuminate\Support\Collection<\Workbench\App\Models\Post|\Workbench\App\Models\Film> $taggable */ public function __construct( public Tag $tag, diff --git a/workbench/database/migrations/2022_05_31_163139_create_tags_table.php b/workbench/database/migrations/2022_05_31_163139_create_tags_table.php index 63de204..9f833df 100644 --- a/workbench/database/migrations/2022_05_31_163139_create_tags_table.php +++ b/workbench/database/migrations/2022_05_31_163139_create_tags_table.php @@ -3,7 +3,6 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Workbench\App\Models\Post; use Workbench\App\Models\Tag; return new class extends Migration diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php index d2afce7..9079f2d 100644 --- a/workbench/database/seeders/DatabaseSeeder.php +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -2,7 +2,6 @@ namespace Workbench\Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder diff --git a/workbench/routes/api.php b/workbench/routes/api.php index c944d86..f9bd1d3 100644 --- a/workbench/routes/api.php +++ b/workbench/routes/api.php @@ -2,7 +2,6 @@ use Illuminate\Support\Facades\Route; use Workbench\App\DataTransferObjects\UpdatePostWithRouteBindingData; -use Workbench\App\DataTransferObjects\UpdatePostWithTags; use Workbench\App\DataTransferObjects\UpdateTagData; /* From 99a04220132ce7c3921227b35348fabe5c2c6395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 22 May 2025 13:57:10 +0200 Subject: [PATCH 04/13] wip --- src/Enums/BuiltInType.php | 2 +- src/Mapper.php | 2 ++ ...rtyMapper.php => BackedEnumDataMapper.php} | 0 ...ropertyMapper.php => CarbonDataMapper.php} | 0 ...rtyMapper.php => CollectionDataMapper.php} | 30 +++++++------------ ...Mapper.php => GenericObjectDataMapper.php} | 0 ...PropertyMapper.php => ModelDataMapper.php} | 0 ...ropertyMapper.php => ObjectDataMapper.php} | 2 +- src/ServiceProvider.php | 23 +++++++------- tests/Unit/DataTransferObjectTest.php | 22 +++++++------- .../app/DataTransferObjects/CreateComment.php | 4 +-- .../CreateManyPostData.php | 3 +- .../DataTransferObjects/CreatePostData.php | 4 +-- .../DataTransferObjects/UpdatePostData.php | 3 +- .../UpdatePostWithDefaultData.php | 4 +-- .../UpdatePostWithRouteBindingData.php | 4 +-- .../UpdatePostWithTags.php | 3 +- .../app/DataTransferObjects/UpdateTagData.php | 4 +-- 18 files changed, 50 insertions(+), 60 deletions(-) rename src/Mappers/{BackedEnumPropertyMapper.php => BackedEnumDataMapper.php} (100%) rename src/Mappers/{CarbonPropertyMapper.php => CarbonDataMapper.php} (100%) rename src/Mappers/{CollectionPropertyMapper.php => CollectionDataMapper.php} (72%) rename src/Mappers/{GenericObjectPropertyMapper.php => GenericObjectDataMapper.php} (100%) rename src/Mappers/{ModelPropertyMapper.php => ModelDataMapper.php} (100%) rename src/Mappers/{ObjectPropertyMapper.php => ObjectDataMapper.php} (99%) diff --git a/src/Enums/BuiltInType.php b/src/Enums/BuiltInType.php index 8f6c4ca..bafc85b 100644 --- a/src/Enums/BuiltInType.php +++ b/src/Enums/BuiltInType.php @@ -8,7 +8,7 @@ enum BuiltInType: string case Array = 'array'; case Iterable = 'iterable'; - case Integer = 'int'; + case Integer = 'integer'; case Float = 'float'; case String = 'string'; diff --git a/src/Mapper.php b/src/Mapper.php index 25f8263..5bc8f23 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use OpenSoutheners\LaravelDto\Enums\BuiltInType; use ReflectionClass; @@ -50,6 +51,7 @@ protected function takeDataFrom(mixed $input): mixed is_object($input->route()) ? $input->route()->parameters() : [], $input instanceof FormRequest ? $input->validated() : $input->all() ), + $input instanceof Collection => $input->all(), is_object($input) => $this->extractProperties($input), default => $input, }; diff --git a/src/Mappers/BackedEnumPropertyMapper.php b/src/Mappers/BackedEnumDataMapper.php similarity index 100% rename from src/Mappers/BackedEnumPropertyMapper.php rename to src/Mappers/BackedEnumDataMapper.php diff --git a/src/Mappers/CarbonPropertyMapper.php b/src/Mappers/CarbonDataMapper.php similarity index 100% rename from src/Mappers/CarbonPropertyMapper.php rename to src/Mappers/CarbonDataMapper.php diff --git a/src/Mappers/CollectionPropertyMapper.php b/src/Mappers/CollectionDataMapper.php similarity index 72% rename from src/Mappers/CollectionPropertyMapper.php rename to src/Mappers/CollectionDataMapper.php index ffd45f9..2f567ba 100644 --- a/src/Mappers/CollectionPropertyMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -32,14 +32,10 @@ public function assert(MappingValue $mappingValue): bool */ public function resolve(MappingValue $mappingValue): mixed { - if ($mappingValue->objectClass === Collection::class) { - return $mappingValue->data instanceof EloquentCollection - ? $mappingValue->data->toBase() - : $mappingValue->data; + if ($mappingValue->objectClass === EloquentCollection::class) { + return $mappingValue->data->toBase(); } - - $propertyType = reset($mappingValue->types); - + if ( count(array_filter($mappingValue->types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 && ! str_contains($mappingValue->types, ',') @@ -47,19 +43,15 @@ public function resolve(MappingValue $mappingValue): mixed return $mappingValue->data; } - if (is_json_structure($mappingValue->data)) { - $collection = Collection::make(json_decode($mappingValue->data, true)); - } else { - $collection = Collection::make( - is_array($mappingValue->data) - ? $mappingValue->data - : explode(',', $mappingValue->data) - ); - } + $collection = match (true) { + is_json_structure($mappingValue->data) => Collection::make(json_decode($mappingValue->data, true)), + is_string($mappingValue->data) => Collection::make(explode(',', $mappingValue->data)), + default => Collection::make($mappingValue->data), + }; - $collectionTypes = $propertyType->getCollectionValueTypes(); + $collectionTypes = $mappingValue->preferredType->getCollectionValueTypes(); - $preferredCollectionType = reset($collectionTypes); + $preferredCollectionType = head($collectionTypes); $preferredCollectionTypeClass = $preferredCollectionType ? $preferredCollectionType->getClassName() : null; $collection = $collection->map(fn ($value) => is_string($value) ? trim($value) : $value) @@ -75,7 +67,7 @@ public function resolve(MappingValue $mappingValue): mixed } } - if ($propertyType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { + if ($mappingValue->preferredType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { $collection = $collection->all(); } diff --git a/src/Mappers/GenericObjectPropertyMapper.php b/src/Mappers/GenericObjectDataMapper.php similarity index 100% rename from src/Mappers/GenericObjectPropertyMapper.php rename to src/Mappers/GenericObjectDataMapper.php diff --git a/src/Mappers/ModelPropertyMapper.php b/src/Mappers/ModelDataMapper.php similarity index 100% rename from src/Mappers/ModelPropertyMapper.php rename to src/Mappers/ModelDataMapper.php diff --git a/src/Mappers/ObjectPropertyMapper.php b/src/Mappers/ObjectDataMapper.php similarity index 99% rename from src/Mappers/ObjectPropertyMapper.php rename to src/Mappers/ObjectDataMapper.php index d67e1df..c99a66b 100644 --- a/src/Mappers/ObjectPropertyMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -82,7 +82,7 @@ public function resolve(MappingValue $mappingValue): mixed continue; } - $preferredType = reset($propertyTypes); + $preferredType = $propertyTypes[0] ?? null; $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); $preferredTypeClass = $preferredType->getClassName(); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 63cc28c..80f7902 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -7,21 +7,20 @@ use OpenSoutheners\LaravelDto\Attributes\Validate; use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; use OpenSoutheners\LaravelDto\Commands\DtoTypescriptGenerateCommand; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; -use OpenSoutheners\LaravelDto\PropertyMappers\PropertyMapper; +use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; +use OpenSoutheners\LaravelDto\Mappers; use ReflectionClass; class ServiceProvider extends BaseServiceProvider { protected static $mappers = [ - PropertyMappers\CollectionPropertyMapper::class, - PropertyMappers\ModelPropertyMapper::class, - - PropertyMappers\CarbonPropertyMapper::class, - PropertyMappers\BackedEnumPropertyMapper::class, - PropertyMappers\GenericObjectPropertyMapper::class, - PropertyMappers\ObjectPropertyMapper::class, + Mappers\CollectionDataMapper::class, + Mappers\ModelDataMapper::class, + Mappers\CarbonDataMapper::class, + Mappers\BackedEnumDataMapper::class, + Mappers\GenericObjectDataMapper::class, + Mappers\ObjectDataMapper::class, ]; /** @@ -40,7 +39,7 @@ public function boot() } $this->app->beforeResolving( - DataTransferObject::class, + RouteTransferableObject::class, function ($dataClass, $parameters, $app) { /** @var \Illuminate\Foundation\Application $app */ $app->scoped($dataClass, function () use ($dataClass, $app) { @@ -70,7 +69,7 @@ public function register() /** * Register new dynamic mappers. */ - public function registerMapper(string|array $mapper, bool $replacing = false): void + public static function registerMapper(string|array $mapper, bool $replacing = false): void { $mappers = (array) $mapper; @@ -89,7 +88,7 @@ public static function getMappers(): array foreach (static::$mappers as $mapper) { $mapperInstance = new $mapper; - if ($mapperInstance instanceof PropertyMapper) { + if ($mapperInstance instanceof Mappers\DataMapper) { $mappers[] = $mapperInstance; } } diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index f06dbca..33103b8 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -8,7 +8,8 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Mockery; -use OpenSoutheners\LaravelDto\ObjectMapper; +use OpenSoutheners\LaravelDto\Mappers; +use OpenSoutheners\LaravelDto\ServiceProvider; use PHPUnit\Framework\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; @@ -23,13 +24,14 @@ protected function setUp(): void { parent::setUp(); - ObjectMapper::registerMapper([ - new PropertyMappers\ModelPropertyMapper, - new PropertyMappers\CollectionPropertyMapper, - new PropertyMappers\ObjectPropertyMapper, - new PropertyMappers\GenericObjectPropertyMapper, - new PropertyMappers\CarbonPropertyMapper, - new PropertyMappers\BackedEnumPropertyMapper, + ServiceProvider::registerMapper([ + Mappers\CollectionDataMapper::class, + Mappers\ModelDataMapper::class, + + Mappers\CarbonDataMapper::class, + Mappers\BackedEnumDataMapper::class, + Mappers\GenericObjectDataMapper::class, + Mappers\ObjectDataMapper::class, ]); $mockedConfig = Mockery::mock(Repository::class); @@ -245,8 +247,8 @@ public function test_data_transfer_object_date_properties_does_not_get_mapped_fr ])->to(CreatePostData::class); $this->assertTrue($data->dates instanceof Collection); - $this->assertFalse($data->dates->first() instanceof Carbon); - $this->assertIsString($data->dates->first()); + $this->assertTrue($data->dates->first() instanceof Carbon); + $this->assertTrue($data->dates->last() instanceof Carbon); } public function test_data_transfer_object_sent_into_another_as_collected_will_be_mapped_from_array() diff --git a/workbench/app/DataTransferObjects/CreateComment.php b/workbench/app/DataTransferObjects/CreateComment.php index 61713b1..637ac11 100644 --- a/workbench/app/DataTransferObjects/CreateComment.php +++ b/workbench/app/DataTransferObjects/CreateComment.php @@ -2,9 +2,7 @@ namespace Workbench\App\DataTransferObjects; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; - -class CreateComment implements DataTransferObject +class CreateComment { public function __construct( public string $content, diff --git a/workbench/app/DataTransferObjects/CreateManyPostData.php b/workbench/app/DataTransferObjects/CreateManyPostData.php index 2efde25..58878de 100644 --- a/workbench/app/DataTransferObjects/CreateManyPostData.php +++ b/workbench/app/DataTransferObjects/CreateManyPostData.php @@ -3,9 +3,8 @@ namespace Workbench\App\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; -class CreateManyPostData implements DataTransferObject +class CreateManyPostData { /** * @param \Illuminate\Support\Collection<\Workbench\App\DataTransferObjects\CreatePostData> $posts diff --git a/workbench/app/DataTransferObjects/CreatePostData.php b/workbench/app/DataTransferObjects/CreatePostData.php index f2885eb..676aa74 100644 --- a/workbench/app/DataTransferObjects/CreatePostData.php +++ b/workbench/app/DataTransferObjects/CreatePostData.php @@ -5,13 +5,13 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\Authenticated; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; use stdClass; use Workbench\App\Enums\PostStatus; use Workbench\App\Models\Post; use Workbench\App\Models\User; -class CreatePostData implements DataTransferObject +class CreatePostData implements RouteTransferableObject { public mixed $authorEmail = 'me@d8vjork.com'; diff --git a/workbench/app/DataTransferObjects/UpdatePostData.php b/workbench/app/DataTransferObjects/UpdatePostData.php index e6ed743..4d4abea 100644 --- a/workbench/app/DataTransferObjects/UpdatePostData.php +++ b/workbench/app/DataTransferObjects/UpdatePostData.php @@ -2,10 +2,9 @@ namespace Workbench\App\DataTransferObjects; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostData implements DataTransferObject +class UpdatePostData { /** * @param string[] $tags diff --git a/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php b/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php index e04eeb9..d5e2eab 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php @@ -4,12 +4,12 @@ use OpenSoutheners\LaravelDto\Attributes\Authenticated; use OpenSoutheners\LaravelDto\Attributes\ResolveModel; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; use Workbench\App\Models\Post; use Workbench\App\Models\Tag; use Workbench\App\Models\User; -class UpdatePostWithDefaultData implements DataTransferObject +class UpdatePostWithDefaultData implements RouteTransferableObject { /** * @param string[] $tags diff --git a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php index 080a1b1..2744bc2 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php @@ -8,7 +8,7 @@ use OpenSoutheners\LaravelDto\Attributes\Authenticated; use OpenSoutheners\LaravelDto\Attributes\ModelWith; use OpenSoutheners\LaravelDto\Attributes\Validate; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; use stdClass; use Workbench\App\Enums\PostStatus; use Workbench\App\Http\Requests\PostUpdateFormRequest; @@ -17,7 +17,7 @@ #[AsType('UpdatePostFormData')] #[Validate(PostUpdateFormRequest::class)] -class UpdatePostWithRouteBindingData implements DataTransferObject +class UpdatePostWithRouteBindingData implements RouteTransferableObject { /** * @param \Illuminate\Support\Collection<\Workbench\App\Models\Tag>|null $tags diff --git a/workbench/app/DataTransferObjects/UpdatePostWithTags.php b/workbench/app/DataTransferObjects/UpdatePostWithTags.php index 0afd1f9..7aad536 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithTags.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithTags.php @@ -3,10 +3,9 @@ namespace Workbench\App\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostWithTags implements DataTransferObject +class UpdatePostWithTags { public function __construct( public Post $post, diff --git a/workbench/app/DataTransferObjects/UpdateTagData.php b/workbench/app/DataTransferObjects/UpdateTagData.php index 887e155..bf0925a 100644 --- a/workbench/app/DataTransferObjects/UpdateTagData.php +++ b/workbench/app/DataTransferObjects/UpdateTagData.php @@ -6,7 +6,7 @@ use OpenSoutheners\LaravelDto\Attributes\Authenticated; use OpenSoutheners\LaravelDto\Attributes\ResolveModel; use OpenSoutheners\LaravelDto\Attributes\Validate; -use OpenSoutheners\LaravelDto\Contracts\DataTransferObject; +use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; use Workbench\App\Http\Requests\TagUpdateFormRequest; use Workbench\App\Models\Film; use Workbench\App\Models\Post; @@ -14,7 +14,7 @@ use Workbench\App\Models\User; #[Validate(TagUpdateFormRequest::class)] -class UpdateTagData implements DataTransferObject +class UpdateTagData implements RouteTransferableObject { /** * @param \Illuminate\Support\Collection<\Workbench\App\Models\Post|\Workbench\App\Models\Film> $taggable From 99cc739fe74234d6d2455880437e796d64026a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 23 May 2025 13:26:54 +0200 Subject: [PATCH 05/13] wip --- src/Attributes/ResolveModel.php | 1 + src/DataTransferObjects/MappingValue.php | 7 ++- src/Mapper.php | 27 +++++++++--- src/Mappers/CarbonDataMapper.php | 4 +- src/Mappers/CollectionDataMapper.php | 13 ++++-- src/Mappers/ModelDataMapper.php | 39 ++++++++++------- src/Mappers/ObjectDataMapper.php | 2 +- src/ServiceProvider.php | 1 - tests/Integration/DataTransferObjectTest.php | 2 +- tests/Unit/DataTransferObjectTest.php | 39 +---------------- tests/Unit/MapperTest.php | 22 ++++++++++ tests/Unit/UnitTestCase.php | 45 +++++++++++++++++++ workbench/app/Models/User.php | 3 ++ workbench/database/factories/UserFactory.php | 46 ++++++++++++++++++++ 14 files changed, 182 insertions(+), 69 deletions(-) create mode 100644 tests/Unit/MapperTest.php create mode 100644 tests/Unit/UnitTestCase.php create mode 100644 workbench/database/factories/UserFactory.php diff --git a/src/Attributes/ResolveModel.php b/src/Attributes/ResolveModel.php index 0dec75b..00c2099 100644 --- a/src/Attributes/ResolveModel.php +++ b/src/Attributes/ResolveModel.php @@ -80,6 +80,7 @@ public function getMorphModel(string $fromPropertyKey, array $properties, array } $morphMap = Relation::morphMap(); + $modelModelClass = array_filter( array_map( fn (string $morphType) => $morphMap[$morphType] ?? null, diff --git a/src/DataTransferObjects/MappingValue.php b/src/DataTransferObjects/MappingValue.php index b7a28df..e7dddd8 100644 --- a/src/DataTransferObjects/MappingValue.php +++ b/src/DataTransferObjects/MappingValue.php @@ -28,6 +28,7 @@ final class MappingValue */ public function __construct( public readonly mixed $data, + public readonly array $allMappingData, public readonly BuiltInType $typeFromData, public readonly ?string $objectClass = null, public readonly ?array $types = null, @@ -38,6 +39,10 @@ public function __construct( $this->preferredTypeClass = $this->preferredType ? ($this->preferredType->getClassName() ?: $objectClass) : $objectClass; - $this->attributes = Collection::make($class ? $class->getAttributes() : []); + if ($property) { + $this->attributes = Collection::make($property->getAttributes()); + } else { + $this->attributes = Collection::make($class ? $class->getAttributes() : []); + } } } diff --git a/src/Mapper.php b/src/Mapper.php index 5bc8f23..88ce759 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -2,6 +2,7 @@ namespace OpenSoutheners\LaravelDto; +use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -16,11 +17,13 @@ final class Mapper protected ?string $dataClass = null; - protected ?string $parentClass = null; + protected ?MappingValue $fromMappingValue = null; protected ?string $property = null; protected array $propertyTypes = []; + + protected bool $runningFromMapper = false; public function __construct(mixed $input) { @@ -52,18 +55,26 @@ protected function takeDataFrom(mixed $input): mixed $input instanceof FormRequest ? $input->validated() : $input->all() ), $input instanceof Collection => $input->all(), + $input instanceof Model => $input, is_object($input) => $this->extractProperties($input), default => $input, }; } - public function through(string|object $value, string $property, array $types): static + /** + * @param array<\Symfony\Component\PropertyInfo\Type> $types + * + * @internal + */ + public function through(MappingValue $mappingValue, string $property, array $types): static { - $this->parentClass = is_object($value) ? get_class($value) : $value; + $this->fromMappingValue = $mappingValue; $this->property = $property; $this->propertyTypes = $types; + + $this->runningFromMapper = true; return $this; } @@ -96,16 +107,20 @@ public function to(?string $output = null) $mappedValue = $this->data; - $reflectionClass = $this->parentClass || $output ? new ReflectionClass($this->parentClass ?: $output) : null; - $reflectionProperty = $reflectionClass && $this->property ? $reflectionClass->getProperty($this->property) : null; + if (is_array($mappedValue)) { + $reflectionClass = $this->fromMappingValue?->class ?? $output ? new ReflectionClass($output) : null; + } else { + $reflectionClass = $output ? new ReflectionClass($output) : null; + } $mappingDataValue = new MappingValue( data: $this->data, + allMappingData: ($this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], typeFromData: BuiltInType::guess($this->data), types: $this->propertyTypes, objectClass: $output, class: $reflectionClass, - property: $reflectionProperty, + property: $this->fromMappingValue?->property ?? ($this->property ? $reflectionClass->getProperty($this->property) : null) ); foreach (ServiceProvider::getMappers() as $mapper) { diff --git a/src/Mappers/CarbonDataMapper.php b/src/Mappers/CarbonDataMapper.php index 58c3ebe..c7ae4af 100644 --- a/src/Mappers/CarbonDataMapper.php +++ b/src/Mappers/CarbonDataMapper.php @@ -30,8 +30,8 @@ public function resolve(MappingValue $mappingValue): mixed default => Carbon::make($mappingValue->data), }; - if ($mappingValue->preferredType === CarbonImmutable::class) { - $dateValue->toImmutable(); + if ($mappingValue->preferredTypeClass === CarbonImmutable::class) { + $dateValue = $dateValue->toImmutable(); } return $dateValue; diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php index 2f567ba..632d4fe 100644 --- a/src/Mappers/CollectionDataMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -35,10 +35,10 @@ public function resolve(MappingValue $mappingValue): mixed if ($mappingValue->objectClass === EloquentCollection::class) { return $mappingValue->data->toBase(); } - + if ( count(array_filter($mappingValue->types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 - && ! str_contains($mappingValue->types, ',') + && ! str_contains($mappingValue->data, ',') ) { return $mappingValue->data; } @@ -59,10 +59,15 @@ public function resolve(MappingValue $mappingValue): mixed if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { - $collection = map($mappingValue->data)->to($preferredCollectionTypeClass); + $collection = map($mappingValue->data) + ->through($mappingValue, $mappingValue->property->getName(), $collectionTypes) + ->to($preferredCollectionTypeClass); } else { + dump($mappingValue->property->getName().' => '.$mappingValue->class->getName()); $collection = $collection->map( - fn ($item) => map($item)->to($preferredCollectionTypeClass) + fn ($item) => map($item) + ->through($mappingValue, $mappingValue->property->getName(), $collectionTypes) + ->to($preferredCollectionTypeClass) ); } } diff --git a/src/Mappers/ModelDataMapper.php b/src/Mappers/ModelDataMapper.php index 793c243..4ecbf7a 100644 --- a/src/Mappers/ModelDataMapper.php +++ b/src/Mappers/ModelDataMapper.php @@ -28,6 +28,12 @@ public function assert(MappingValue $mappingValue): bool */ public function resolve(MappingValue $mappingValue): mixed { + $data = $mappingValue->data; + + if (is_string($data) && str_contains($data, ',')) { + $data = array_filter(explode(',', $data)); + } + $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ @@ -55,7 +61,7 @@ public function resolve(MappingValue $mappingValue): mixed ->toArray(); if (is_array($modelType) && $mappingValue->objectClass === Collection::class) { - $valueClass = get_class($mappingValue->data); + $valueClass = get_class($data); $modelType = $modelClass[array_search($valueClass, $modelClass)]; } @@ -66,14 +72,14 @@ public function resolve(MappingValue $mappingValue): mixed ) { $modelType = $resolveModelAttribute->getMorphModel( $mappingValue->property->getName(), - $mappingValue->class->getProperties(ReflectionProperty::IS_PUBLIC), + $mappingValue->allMappingData, $mappingValue->types === Model::class ? [] : (array) $mappingValue->types ); } if (! is_countable($modelType) || count($modelType) === 1) { return $this->resolveIntoModelInstance( - $mappingValue->data, + $data, ! is_countable($modelType) ? $modelType : $modelType[0], $mappingValue->property->getName(), $modelWithAttributes, @@ -81,18 +87,21 @@ public function resolve(MappingValue $mappingValue): mixed ); } - return Collection::make(array_map( - function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { - if (! is_null($valueB)) { - $lastNonValue = $valueB; - } - - return [$valueA, $valueB ?? $lastNonValue]; - }, - $mappingValue->data instanceof Collection ? $mappingValue->data->all() : (array) $mappingValue->data, - (array) $modelType - ))->mapToGroups(fn (array $value) => [$value[1] => $value[0]])->flatMap(fn (Collection $keys, string $model) => $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute) - ); + return Collection::make( + array_map( + function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { + if (! is_null($valueB)) { + $lastNonValue = $valueB; + } + + return [$valueA, $valueB ?? $lastNonValue]; + }, + $data, + (array) $modelType + ) + ) + ->mapToGroups(fn (array $value) => [$value[1] => $value[0]]) + ->flatMap(fn (Collection $keys, string $model) => $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute)); } /** diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index c99a66b..8c7e49d 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -101,7 +101,7 @@ public function resolve(MappingValue $mappingValue): mixed } $data[$key] = map($value) - ->through($mappingValue->preferredTypeClass, $key, $propertyTypes) + ->through($mappingValue, $key, $propertyTypes) ->to($preferredTypeClass); } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 80f7902..5532e57 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -8,7 +8,6 @@ use OpenSoutheners\LaravelDto\Commands\DtoMakeCommand; use OpenSoutheners\LaravelDto\Commands\DtoTypescriptGenerateCommand; use OpenSoutheners\LaravelDto\Contracts\RouteTransferableObject; -use OpenSoutheners\LaravelDto\Mappers; use ReflectionClass; class ServiceProvider extends BaseServiceProvider diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/Integration/DataTransferObjectTest.php index 598eafb..dda2827 100644 --- a/tests/Integration/DataTransferObjectTest.php +++ b/tests/Integration/DataTransferObjectTest.php @@ -239,7 +239,7 @@ public function test_data_transfer_object_with_morphs_gets_models_bound_of_each_ $response = $this->patchJson('tags/1', [ 'name' => 'Scary', - 'taggable' => '1, 1, 2', + 'taggable' => Collection::make([$myFilm->getKey(), $fooBarPost->getKey(), $helloWorldPost->getKey()])->join(', '), // TODO: Fix mapping by slug // 'taggable' => '1, foo-bar, hello-world', 'taggable_type' => 'film, post', diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index 33103b8..446eabc 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -2,15 +2,8 @@ namespace OpenSoutheners\LaravelDto\Tests\Unit; -use Illuminate\Auth\AuthManager; -use Illuminate\Config\Repository; -use Illuminate\Container\Container; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Mockery; -use OpenSoutheners\LaravelDto\Mappers; -use OpenSoutheners\LaravelDto\ServiceProvider; -use PHPUnit\Framework\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; use Workbench\App\DataTransferObjects\CreatePostData; @@ -18,38 +11,8 @@ use function OpenSoutheners\LaravelDto\map; -class DataTransferObjectTest extends TestCase +class DataTransferObjectTest extends UnitTestCase { - protected function setUp(): void - { - parent::setUp(); - - ServiceProvider::registerMapper([ - Mappers\CollectionDataMapper::class, - Mappers\ModelDataMapper::class, - - Mappers\CarbonDataMapper::class, - Mappers\BackedEnumDataMapper::class, - Mappers\GenericObjectDataMapper::class, - Mappers\ObjectDataMapper::class, - ]); - - $mockedConfig = Mockery::mock(Repository::class); - - $mockedConfig->shouldReceive('get')->andReturn(true); - - Container::getInstance()->bind('config', fn () => $mockedConfig); - - $mockedAuth = Mockery::mock(AuthManager::class); - - $mockedAuth->shouldReceive('check')->andReturn(false); - $mockedAuth->shouldReceive('userResolver')->andReturn(fn () => null); - - Container::getInstance()->bind('auth', fn () => $mockedAuth); - - Container::getInstance()->bind('dto.context.booted', fn () => ''); - } - public function test_data_transfer_object_from_array() { $data = map([ diff --git a/tests/Unit/MapperTest.php b/tests/Unit/MapperTest.php new file mode 100644 index 0000000..cebc0ff --- /dev/null +++ b/tests/Unit/MapperTest.php @@ -0,0 +1,22 @@ +create(); + + $result = map('1')->to(User::class); + + $this->assertInstanceOf(User::class, $result); + $this->assertEquals($user->email, $result->email); + } +} diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php new file mode 100644 index 0000000..27683fc --- /dev/null +++ b/tests/Unit/UnitTestCase.php @@ -0,0 +1,45 @@ +shouldReceive('get')->andReturn(true); + + Container::getInstance()->bind('config', fn () => $mockedConfig); + } + + public function actAsUser($user = null) + { + $mockedAuth = Mockery::mock(AuthManager::class); + + $mockedAuth->shouldReceive('check')->andReturn(false); + $mockedAuth->shouldReceive('userResolver')->andReturn(fn () => $user); + + Container::getInstance()->bind('auth', fn () => $mockedAuth); + } +} diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index 950b194..c2cfe7e 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -2,10 +2,13 @@ namespace Workbench\App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { + use HasFactory; + /** * The attributes that aren't mass assignable. * diff --git a/workbench/database/factories/UserFactory.php b/workbench/database/factories/UserFactory.php new file mode 100644 index 0000000..5bab9cb --- /dev/null +++ b/workbench/database/factories/UserFactory.php @@ -0,0 +1,46 @@ + + */ +class UserFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = User::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => Str::random(), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} From db45ed18af427ee1b7f7e82a5457f391b4d2a6f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Tue, 3 Jun 2025 19:45:51 +0200 Subject: [PATCH 06/13] wip --- composer.json | 2 +- src/DataTransferObjects/MappingValue.php | 24 ++- src/Enums/BuiltInType.php | 48 ------ src/Mapper.php | 70 +++------ src/Mappers/BackedEnumDataMapper.php | 10 +- src/Mappers/CarbonDataMapper.php | 15 +- src/Mappers/CollectionDataMapper.php | 60 +++----- src/Mappers/DataMapper.php | 18 ++- src/Mappers/GenericObjectDataMapper.php | 12 +- src/Mappers/ModelDataMapper.php | 184 ++++++++++++++--------- src/Mappers/ObjectDataMapper.php | 98 ++++-------- src/PropertyInfoExtractor.php | 29 ++++ src/ServiceProvider.php | 22 +-- src/functions.php | 2 +- tests/Unit/MapperTest.php | 109 ++++++++++++++ 15 files changed, 378 insertions(+), 325 deletions(-) delete mode 100644 src/Enums/BuiltInType.php create mode 100644 src/PropertyInfoExtractor.php diff --git a/composer.json b/composer.json index 4e2456f..b0ee01d 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "illuminate/support": "^11.0 || ^12.0", "open-southeners/extended-laravel": "~0.4", "phpdocumentor/reflection-docblock": "^5.3", - "symfony/property-info": "^6.0 || ^7.0" + "symfony/property-info": "^7.3" }, "require-dev": { "larastan/larastan": "^3.0", diff --git a/src/DataTransferObjects/MappingValue.php b/src/DataTransferObjects/MappingValue.php index e7dddd8..5e12f01 100644 --- a/src/DataTransferObjects/MappingValue.php +++ b/src/DataTransferObjects/MappingValue.php @@ -3,10 +3,7 @@ namespace OpenSoutheners\LaravelDto\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\Enums\BuiltInType; -use ReflectionClass; -use ReflectionProperty; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; final class MappingValue { @@ -24,25 +21,26 @@ final class MappingValue /** * @param class-string|null $objectClass + * @param class-string|null $collectClass * @param array|null $types */ public function __construct( - public readonly mixed $data, + public mixed $data, public readonly array $allMappingData, - public readonly BuiltInType $typeFromData, + public readonly ?string $objectClass = null, + public readonly ?string $collectClass = null, + public readonly ?array $types = null, - public readonly ?ReflectionClass $class = null, - public readonly ?ReflectionProperty $property = null, ) { $this->preferredType = $types ? (reset($types) ?? null) : null; $this->preferredTypeClass = $this->preferredType ? ($this->preferredType->getClassName() ?: $objectClass) : $objectClass; - if ($property) { - $this->attributes = Collection::make($property->getAttributes()); - } else { - $this->attributes = Collection::make($class ? $class->getAttributes() : []); - } + // if ($property) { + // $this->attributes = Collection::make($property->getAttributes()); + // } else { + // $this->attributes = Collection::make($class ? $class->getAttributes() : []); + // } } } diff --git a/src/Enums/BuiltInType.php b/src/Enums/BuiltInType.php deleted file mode 100644 index bafc85b..0000000 --- a/src/Enums/BuiltInType.php +++ /dev/null @@ -1,48 +0,0 @@ - $loops) { - $type = $types[$loops]; - - $truth = $type instanceof static ? $type === $this : $type === $this->value; - $loops++; - } - - return $truth; - } -} diff --git a/src/Mapper.php b/src/Mapper.php index 88ce759..c344c3d 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; +use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use OpenSoutheners\LaravelDto\Enums\BuiltInType; @@ -17,8 +18,10 @@ final class Mapper protected ?string $dataClass = null; + protected ?string $throughClass = null; + protected ?MappingValue $fromMappingValue = null; - + protected ?string $property = null; protected array $propertyTypes = []; @@ -27,6 +30,10 @@ final class Mapper public function __construct(mixed $input) { + if (is_array($input) && count($input) === 1) { + $input = reset($input); + } + if (is_object($input)) { $this->dataClass = get_class($input); } @@ -62,20 +69,12 @@ protected function takeDataFrom(mixed $input): mixed } /** - * @param array<\Symfony\Component\PropertyInfo\Type> $types - * - * @internal + * Map values through class. */ - public function through(MappingValue $mappingValue, string $property, array $types): static + public function through(string $class): static { - $this->fromMappingValue = $mappingValue; - - $this->property = $property; - - $this->propertyTypes = $types; + $this->throughClass = $class; - $this->runningFromMapper = true; - return $this; } @@ -87,50 +86,25 @@ public function through(MappingValue $mappingValue, string $property, array $typ */ public function to(?string $output = null) { - // TODO: Move to ModelMapper class - // if ($output && is_a($output, Model::class, true)) { - // /** @var Model $model */ - // $model = new $output; - - // foreach ($this->data as $key => $value) { - // if ($model->isRelation($key) && $model->$key() instanceof BelongsTo) { - // $model->$key()->associate($value); - // } - - // $model->fill([$key => $value]); - // } - - // return $model; - // } - $output ??= $this->dataClass; - $mappedValue = $this->data; - - if (is_array($mappedValue)) { - $reflectionClass = $this->fromMappingValue?->class ?? $output ? new ReflectionClass($output) : null; - } else { - $reflectionClass = $output ? new ReflectionClass($output) : null; - } + // if (is_array($this->data)) { + // $reflectionClass = $this->fromMappingValue?->class ?? $output ? new ReflectionClass($output) : null; + // } else { + // $reflectionClass = $output ? new ReflectionClass($output) : null; + // } $mappingDataValue = new MappingValue( data: $this->data, - allMappingData: ($this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], - typeFromData: BuiltInType::guess($this->data), + allMappingData: (!$this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], types: $this->propertyTypes, objectClass: $output, - class: $reflectionClass, - property: $this->fromMappingValue?->property ?? ($this->property ? $reflectionClass->getProperty($this->property) : null) + collectClass: $this->throughClass, ); - foreach (ServiceProvider::getMappers() as $mapper) { - if ($mapper->assert($mappingDataValue)) { - $mappedValue = $mapper->resolve($mappingDataValue); - - break; - } - } - - return $mappedValue; + return app(Pipeline::class) + ->through(ServiceProvider::getMappers()) + ->send($mappingDataValue) + ->then(fn (MappingValue $mappingValue) => $mappingValue->data); } } diff --git a/src/Mappers/BackedEnumDataMapper.php b/src/Mappers/BackedEnumDataMapper.php index 8e0b756..662aca1 100644 --- a/src/Mappers/BackedEnumDataMapper.php +++ b/src/Mappers/BackedEnumDataMapper.php @@ -4,23 +4,25 @@ use BackedEnum; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; +use ReflectionEnum; -final class BackedEnumDataMapper implements DataMapper +final class BackedEnumDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. */ public function assert(MappingValue $mappingValue): bool { - return is_subclass_of($mappingValue->preferredTypeClass, BackedEnum::class); + return is_subclass_of($mappingValue->preferredTypeClass, BackedEnum::class) + && gettype($mappingValue->data) === (new ReflectionEnum($mappingValue->preferredTypeClass))->getBackingType()->getName(); } /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { - return $mappingValue->preferredTypeClass::tryFrom($mappingValue->data) ?? ( + $mappingValue->data = $mappingValue->preferredTypeClass::tryFrom($mappingValue->data) ?? ( count($mappingValue->types) > 1 ? $mappingValue->data : null diff --git a/src/Mappers/CarbonDataMapper.php b/src/Mappers/CarbonDataMapper.php index c7ae4af..9ce94c5 100644 --- a/src/Mappers/CarbonDataMapper.php +++ b/src/Mappers/CarbonDataMapper.php @@ -6,16 +6,15 @@ use Carbon\CarbonInterface; use Illuminate\Support\Carbon; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; -use OpenSoutheners\LaravelDto\Enums\BuiltInType; -final class CarbonDataMapper implements DataMapper +final class CarbonDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. */ public function assert(MappingValue $mappingValue): bool { - return $mappingValue->typeFromData->assert(BuiltInType::String, BuiltInType::Integer) + return in_array(gettype($mappingValue->data), ['string', 'integer'], true) && ($mappingValue->preferredTypeClass === CarbonInterface::class || is_subclass_of($mappingValue->preferredTypeClass, CarbonInterface::class)); } @@ -23,17 +22,15 @@ public function assert(MappingValue $mappingValue): bool /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { - $dateValue = match (true) { - $mappingValue->typeFromData === BuiltInType::Integer || is_numeric($mappingValue->data) => Carbon::createFromTimestamp($mappingValue->data), + $mappingValue->data = match (true) { + gettype($mappingValue->data) === 'integer' || is_numeric($mappingValue->data) => Carbon::createFromTimestamp($mappingValue->data), default => Carbon::make($mappingValue->data), }; if ($mappingValue->preferredTypeClass === CarbonImmutable::class) { - $dateValue = $dateValue->toImmutable(); + $mappingValue->data = $mappingValue->data->toImmutable(); } - - return $dateValue; } } diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php index 632d4fe..6d12039 100644 --- a/src/Mappers/CollectionDataMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -6,41 +6,34 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; use function OpenSoutheners\LaravelDto\map; -final class CollectionDataMapper implements DataMapper +final class CollectionDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. */ public function assert(MappingValue $mappingValue): bool { - return $mappingValue->preferredType?->isCollection() + return ($mappingValue->collectClass === Collection::class && is_array($mappingValue->data)) + || ($mappingValue->collectClass === Collection::class && is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) + || $mappingValue->preferredType instanceof Type\CollectionType || $mappingValue->preferredTypeClass === Collection::class || $mappingValue->preferredTypeClass === EloquentCollection::class; } /** * Resolve mapper that runs once assert returns true. - * - * @param string[]|string $types - * @param Collection<\ReflectionAttribute> $attributes - * @param array $properties */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { if ($mappingValue->objectClass === EloquentCollection::class) { - return $mappingValue->data->toBase(); - } - - if ( - count(array_filter($mappingValue->types, fn (Type $type) => $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING)) > 0 - && ! str_contains($mappingValue->data, ',') - ) { - return $mappingValue->data; + $mappingValue->data = $mappingValue->data->toBase(); + + return; } $collection = match (true) { @@ -48,34 +41,15 @@ public function resolve(MappingValue $mappingValue): mixed is_string($mappingValue->data) => Collection::make(explode(',', $mappingValue->data)), default => Collection::make($mappingValue->data), }; - - $collectionTypes = $mappingValue->preferredType->getCollectionValueTypes(); - - $preferredCollectionType = head($collectionTypes); - $preferredCollectionTypeClass = $preferredCollectionType ? $preferredCollectionType->getClassName() : null; - - $collection = $collection->map(fn ($value) => is_string($value) ? trim($value) : $value) - ->filter(fn ($item) => ! blank($item)); - - if ($preferredCollectionType && $preferredCollectionType->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT) { - if (is_subclass_of($preferredCollectionTypeClass, Model::class)) { - $collection = map($mappingValue->data) - ->through($mappingValue, $mappingValue->property->getName(), $collectionTypes) - ->to($preferredCollectionTypeClass); - } else { - dump($mappingValue->property->getName().' => '.$mappingValue->class->getName()); - $collection = $collection->map( - fn ($item) => map($item) - ->through($mappingValue, $mappingValue->property->getName(), $collectionTypes) - ->to($preferredCollectionTypeClass) - ); - } - } - - if ($mappingValue->preferredType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { - $collection = $collection->all(); + + if ($mappingValue->preferredTypeClass) { + $collection = $collection->map(fn ($value) => map($value)->to($mappingValue->preferredTypeClass)); } - return $collection; + // if ($mappingValue->preferredType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { + // $collection = $collection->all(); + // } + + $mappingValue->data = $collection; } } diff --git a/src/Mappers/DataMapper.php b/src/Mappers/DataMapper.php index 4aa994c..1a3026a 100644 --- a/src/Mappers/DataMapper.php +++ b/src/Mappers/DataMapper.php @@ -2,17 +2,29 @@ namespace OpenSoutheners\LaravelDto\Mappers; +use Closure; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; -interface DataMapper +abstract class DataMapper { /** * Assert that this mapper resolves property with types given. */ - public function assert(MappingValue $mappingValue): bool; + abstract public function assert(MappingValue $mappingValue): bool; /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed; + abstract public function resolve(MappingValue $mappingValue): void; + + public function __invoke(MappingValue $mappingValue, Closure $next) + { + if (!$this->assert($mappingValue)) { + return $next($mappingValue); + } + + $this->resolve($mappingValue); + + return $next($mappingValue); + } } diff --git a/src/Mappers/GenericObjectDataMapper.php b/src/Mappers/GenericObjectDataMapper.php index 517f5b9..56ed295 100644 --- a/src/Mappers/GenericObjectDataMapper.php +++ b/src/Mappers/GenericObjectDataMapper.php @@ -7,7 +7,7 @@ use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; -final class GenericObjectDataMapper implements DataMapper +final class GenericObjectDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. @@ -21,12 +21,10 @@ public function assert(MappingValue $mappingValue): bool /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { - if (is_array($mappingValue->data)) { - return (object) $mappingValue->data; - } - - return json_decode($mappingValue->data); + $mappingValue->data = is_array($mappingValue->data) + ? (object) $mappingValue->data + : json_decode($mappingValue->data); } } diff --git a/src/Mappers/ModelDataMapper.php b/src/Mappers/ModelDataMapper.php index 4ecbf7a..f06a1fd 100644 --- a/src/Mappers/ModelDataMapper.php +++ b/src/Mappers/ModelDataMapper.php @@ -2,8 +2,13 @@ namespace OpenSoutheners\LaravelDto\Mappers; +use Closure; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as DatabaseCollection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Attributes\ModelWith; use OpenSoutheners\LaravelDto\Attributes\ResolveModel; @@ -12,7 +17,9 @@ use ReflectionProperty; use Symfony\Component\PropertyInfo\Type; -final class ModelDataMapper implements DataMapper +use function OpenSoutheners\LaravelDto\map; + +final class ModelDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. @@ -26,82 +33,115 @@ public function assert(MappingValue $mappingValue): bool /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { - $data = $mappingValue->data; - - if (is_string($data) && str_contains($data, ',')) { - $data = array_filter(explode(',', $data)); + if (is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data)) { + /** @var Model $modelInstance */ + $modelInstance = new $mappingValue->preferredTypeClass; + + foreach ($mappingValue->data as $key => $value) { + if ($modelInstance->isRelation($key) && $modelInstance->$key() instanceof BelongsTo) { + $modelInstance->$key()->associate($value); + + continue; + } + + if ($modelInstance->isRelation($key) && $modelInstance->$key() instanceof HasMany) { + $modelInstance->setRelation($key, map($value)->to(get_class($modelInstance->$key()->getModel()))); + + continue; + } + + $modelInstance->fill([$key => $value]); + } + + $mappingValue->data = $modelInstance; + + return; } - $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); - - /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ - $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); - - /** @var \OpenSoutheners\LaravelDto\Attributes\ResolveModel|null $resolveModelAttribute */ - $resolveModelAttribute = $resolveModelAttributeReflector - ? $resolveModelAttributeReflector->newInstance() - : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($mappingValue->property->getName())); - - $modelClass = Collection::make($mappingValue->types ?? [$mappingValue->preferredTypeClass]) - ->map(fn (Type $type): string => $type->getClassName()) - ->filter(fn (string $typeClass): bool => is_a($typeClass, Model::class, true)) - ->unique() - ->values() - ->toArray(); - - $modelType = count($modelClass) === 1 ? reset($modelClass) : $modelClass; - $valueClass = null; - - /** @var array|null $modelWithAttributes */ - $modelWithAttributes = $mappingValue->attributes - ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ModelWith::class) - ->mapWithKeys(fn (ReflectionAttribute $reflection) => [$reflection->newInstance()->type ?? $modelType => $reflection->newInstance()->relations]) - ->toArray(); - - if (is_array($modelType) && $mappingValue->objectClass === Collection::class) { - $valueClass = get_class($data); - - $modelType = $modelClass[array_search($valueClass, $modelClass)]; + if (is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) { + $mappingValue->data = array_filter(explode(',', $mappingValue->data)); } - - if ( - (! is_array($modelType) && $modelType === Model::class) - || ($resolveModelAttribute && is_array($modelType)) - ) { - $modelType = $resolveModelAttribute->getMorphModel( - $mappingValue->property->getName(), - $mappingValue->allMappingData, - $mappingValue->types === Model::class ? [] : (array) $mappingValue->types - ); + + if (count($mappingValue->types) <= 1) { + $mappingValue->data = $this->resolveIntoModelInstance($mappingValue->data, $mappingValue->preferredTypeClass); } - - if (! is_countable($modelType) || count($modelType) === 1) { - return $this->resolveIntoModelInstance( - $data, - ! is_countable($modelType) ? $modelType : $modelType[0], - $mappingValue->property->getName(), - $modelWithAttributes, - $resolveModelAttribute - ); + + if ($mappingValue->collectClass === Collection::class) { + $mappingValue->data = $mappingValue->data instanceof DatabaseCollection + ? $mappingValue->data->toBase() + : Collection::make($mappingValue->data); } - - return Collection::make( - array_map( - function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { - if (! is_null($valueB)) { - $lastNonValue = $valueB; - } + + // $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); + + // /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ + // $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); + + // /** @var \OpenSoutheners\LaravelDto\Attributes\ResolveModel|null $resolveModelAttribute */ + // $resolveModelAttribute = $resolveModelAttributeReflector + // ? $resolveModelAttributeReflector->newInstance() + // : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($mappingValue->property->getName())); + + // $modelClass = Collection::make($mappingValue->types ?? [$mappingValue->preferredTypeClass]) + // ->map(fn (Type $type): string => $type->getClassName()) + // ->filter(fn (string $typeClass): bool => is_a($typeClass, Model::class, true)) + // ->unique() + // ->values() + // ->toArray(); + + // $modelType = count($modelClass) === 1 ? reset($modelClass) : $modelClass; + // $valueClass = null; + + // /** @var array|null $modelWithAttributes */ + // $modelWithAttributes = $mappingValue->attributes + // ->filter(fn (ReflectionAttribute $reflection) => $reflection->getName() === ModelWith::class) + // ->mapWithKeys(fn (ReflectionAttribute $reflection) => [$reflection->newInstance()->type ?? $modelType => $reflection->newInstance()->relations]) + // ->toArray(); + + // if (is_array($modelType) && $mappingValue->objectClass === Collection::class) { + // $valueClass = get_class($data); + + // $modelType = $modelClass[array_search($valueClass, $modelClass)]; + // } + + // if ( + // (! is_array($modelType) && $modelType === Model::class) + // || ($resolveModelAttribute && is_array($modelType)) + // ) { + // $modelType = $resolveModelAttribute->getMorphModel( + // $mappingValue->property->getName(), + // $mappingValue->allMappingData, + // $mappingValue->types === Model::class ? [] : (array) $mappingValue->types + // ); + // } + + // if (! is_countable($modelType) || count($modelType) === 1) { + // return $this->resolveIntoModelInstance( + // $data, + // ! is_countable($modelType) ? $modelType : $modelType[0], + // $mappingValue->property->getName(), + // $modelWithAttributes, + // $resolveModelAttribute + // ); + // } + + // return Collection::make( + // array_map( + // function (mixed $valueA, mixed $valueB) use (&$lastNonValue): array { + // if (! is_null($valueB)) { + // $lastNonValue = $valueB; + // } - return [$valueA, $valueB ?? $lastNonValue]; - }, - $data, - (array) $modelType - ) - ) - ->mapToGroups(fn (array $value) => [$value[1] => $value[0]]) - ->flatMap(fn (Collection $keys, string $model) => $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute)); + // return [$valueA, $valueB ?? $lastNonValue]; + // }, + // $data, + // (array) $modelType + // ) + // ) + // ->mapToGroups(fn (array $value) => [$value[1] => $value[0]]) + // ->flatMap(fn (Collection $keys, string $model) => $this->resolveIntoModelInstance($keys, $model, $mappingValue->property->getName(), $modelWithAttributes, $resolveModelAttribute)); } /** @@ -143,12 +183,12 @@ protected function getModelInstance(string $model, mixed $id, mixed $usingAttrib * * @param array $withAttributes */ - protected function resolveIntoModelInstance(mixed $keys, string $modelClass, string $propertyKey, array $withAttributes = [], ?ResolveModel $bindingAttribute = null): mixed + protected function resolveIntoModelInstance(mixed $keys, string $modelClass, ?string $propertyKey = null, array $withAttributes = [], ?ResolveModel $bindingAttribute = null): mixed { $usingAttribute = null; $with = []; - if ($bindingAttribute) { + if ($bindingAttribute && $propertyKey) { $with = $withAttributes[$modelClass] ?? []; $usingAttribute = $bindingAttribute->getBindingAttribute($propertyKey, $modelClass, $with); } diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 8c7e49d..52de3d1 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -8,18 +8,17 @@ use Illuminate\Support\Str; use OpenSoutheners\LaravelDto\Attributes\NormaliseProperties; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; +use OpenSoutheners\LaravelDto\PropertyInfoExtractor; use ReflectionAttribute; use ReflectionClass; +use ReflectionProperty; use stdClass; -use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; -use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\TypeInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; use function OpenSoutheners\LaravelDto\map; -final class ObjectDataMapper implements DataMapper +final class ObjectDataMapper extends DataMapper { /** * Assert that this mapper resolves property with types given. @@ -42,10 +41,11 @@ public function assert(MappingValue $mappingValue): bool /** * Resolve mapper that runs once assert returns true. */ - public function resolve(MappingValue $mappingValue): mixed + public function resolve(MappingValue $mappingValue): void { + $class = new ReflectionClass($mappingValue->preferredTypeClass); + $data = []; - $propertiesInfo = self::getPropertiesInfoFrom($mappingValue->preferredTypeClass); $mappingData = is_string($mappingValue->data) ? json_decode($mappingValue->data, true) : $mappingValue->data; @@ -54,19 +54,14 @@ public function resolve(MappingValue $mappingValue): mixed array_values($mappingData) ); - foreach ($propertiesInfo as $key => $propertyTypes) { + foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $key = $property->getName(); $value = $propertiesData[$key] ?? null; - if (count($propertyTypes) === 0) { - $data[$key] = $value; - - continue; - } - + $type = app(PropertyInfoExtractor::class)->typeInfo($class->getName(), $key); + /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ - $propertyAttributes = Collection::make( - $mappingValue->class->getProperty($key)->getAttributes() - ); + $propertyAttributes = Collection::make($property->getAttributes()); $containerAttribute = $propertyAttributes->filter( fn (ReflectionAttribute $attribute) => is_subclass_of($attribute->getName(), ContextualAttribute::class) @@ -81,31 +76,26 @@ public function resolve(MappingValue $mappingValue): mixed if (is_null($value)) { continue; } - - $preferredType = $propertyTypes[0] ?? null; - $propertyTypesClasses = array_filter(array_map(fn (Type $type) => $type->getClassName(), $propertyTypes)); - $preferredTypeClass = $preferredType->getClassName(); - - if ( - $preferredTypeClass - && ! is_array($value) - && ! $preferredType->isCollection() - && $preferredTypeClass !== Collection::class - && ! is_a($preferredTypeClass, Model::class, true) - && (is_a($value, $preferredTypeClass, true) - || (is_object($value) && in_array(get_class($value), $propertyTypesClasses))) - ) { - $data[$key] = $value; - + + if ($type instanceof Type\NullableType) { + $type = $type->getWrappedType(); + } + + if ($type instanceof Type\CollectionType) { + $data[$key] = map($value) + ->through((string) $type->getWrappedType()->getWrappedType()) + ->to($type->getCollectionValueType()); + continue; } - - $data[$key] = map($value) - ->through($mappingValue, $key, $propertyTypes) - ->to($preferredTypeClass); + + $data[$key] = match (true) { + $type instanceof Type\ObjectType => map($value)->to((string) $type), + default => $value, + }; } - return new $mappingValue->preferredTypeClass(...$data); + $mappingValue->data = new $mappingValue->preferredTypeClass(...$data); } /** @@ -113,7 +103,9 @@ public function resolve(MappingValue $mappingValue): mixed */ protected function normalisePropertyKey(MappingValue $mappingValue, string $key): ?string { - $normaliseProperty = count($mappingValue->class->getAttributes(NormaliseProperties::class)) > 0 + $class = new ReflectionClass($mappingValue->objectClass); + + $normaliseProperty = count($class->getAttributes(NormaliseProperties::class)) > 0 ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); if (! $normaliseProperty) { @@ -132,32 +124,4 @@ protected function normalisePropertyKey(MappingValue $mappingValue, string $key) default => null }; } - - /** - * Get instance of property info extractor. - * - * @return array> - */ - public static function getPropertiesInfoFrom(string $class, ?string $property = null): array - { - $phpStanExtractor = new PhpStanExtractor; - $reflectionExtractor = new ReflectionExtractor; - - $extractor = new PropertyInfoExtractor( - [$reflectionExtractor], - [$phpStanExtractor, $reflectionExtractor], - ); - - if ($property) { - return [$property => $extractor->getTypes($class, $property) ?? []]; - } - - $propertiesInfo = []; - - foreach ($extractor->getProperties($class) as $key) { - $propertiesInfo[$key] = $extractor->getTypes($class, $key) ?? []; - } - - return $propertiesInfo; - } } diff --git a/src/PropertyInfoExtractor.php b/src/PropertyInfoExtractor.php new file mode 100644 index 0000000..d7f6520 --- /dev/null +++ b/src/PropertyInfoExtractor.php @@ -0,0 +1,29 @@ +extractor = new Extractor( + [$reflectionExtractor], + [$phpStanExtractor, $reflectionExtractor], + ); + } + + public function typeInfo(string $class, string $property, array $context = []): ?Type + { + return $this->extractor->getType($class, $property, $context); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 5532e57..b4af58b 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -53,6 +53,9 @@ function ($dataClass, $parameters, $app) { }); } ); + + $this->app->instance(PropertyInfoExtractor::class, new PropertyInfoExtractor()); + $this->app->alias(PropertyInfoExtractor::class, 'propertyInfo'); } /** @@ -78,20 +81,21 @@ public static function registerMapper(string|array $mapper, bool $replacing = fa /** * Get dynamic mappers. * - * @return array + * @return array> */ public static function getMappers(): array { - $mappers = []; + return static::$mappers; + // $mappers = []; - foreach (static::$mappers as $mapper) { - $mapperInstance = new $mapper; + // foreach (static::$mappers as $mapper) { + // $mapperInstance = new $mapper; - if ($mapperInstance instanceof Mappers\DataMapper) { - $mappers[] = $mapperInstance; - } - } + // if ($mapperInstance instanceof Mappers\DataMapper) { + // $mappers[] = $mapperInstance; + // } + // } - return $mappers; + // return $mappers; } } diff --git a/src/functions.php b/src/functions.php index 70a8764..bed5b1c 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,7 +2,7 @@ namespace OpenSoutheners\LaravelDto; -function map(mixed $input): Mapper +function map(mixed ...$input): Mapper { return new Mapper($input); } diff --git a/tests/Unit/MapperTest.php b/tests/Unit/MapperTest.php index cebc0ff..81ef387 100644 --- a/tests/Unit/MapperTest.php +++ b/tests/Unit/MapperTest.php @@ -2,7 +2,12 @@ namespace OpenSoutheners\LaravelDto\Tests\Unit; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Collection as DatabaseCollection; +use Illuminate\Support\Collection; use OpenSoutheners\LaravelDto\Tests\Integration\TestCase; +use stdClass; +use Workbench\App\Enums\PostStatus; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -19,4 +24,108 @@ public function testMapNumericIdToModelResultsInModelInstance() $this->assertInstanceOf(User::class, $result); $this->assertEquals($user->email, $result->email); } + + public function testMapMultipleNumericIdsToModelResultsInCollectionOfModelInstances() + { + $users = UserFactory::new()->count(2)->create(); + + $result = map('1, 2')->to(User::class); + + $this->assertInstanceOf(DatabaseCollection::class, $result); + $this->assertEquals($users->first()->email, $result->first()->email); + $this->assertEquals($users->last()->email, $result->last()->email); + } + + public function testMapMultipleNumericIdsAsArgsToModelResultsInCollectionOfModelInstances() + { + $users = UserFactory::new()->count(2)->create(); + + $result = map(1, 2)->to(User::class); + + $this->assertInstanceOf(DatabaseCollection::class, $result); + $this->assertEquals($users->first()->email, $result->first()->email); + $this->assertEquals($users->last()->email, $result->last()->email); + } + + public function testMapMultipleNumericIdsToModelThroughBaseCollectionResultsInBaseCollectionOfModelInstances() + { + $users = UserFactory::new()->count(2)->create(); + + $result = map('1, 2')->through(Collection::class)->to(User::class); + + $this->assertTrue(get_class($result) === Collection::class); + $this->assertEquals($users->first()->email, $result->first()->email); + $this->assertEquals($users->last()->email, $result->last()->email); + } + + public function testMapNumericTimestampToCarbonResultsInCarbonInstance() + { + $timestamp = 1747939147; + $result = map($timestamp)->to(Carbon::class); + + $this->assertTrue(get_class($result) === \Illuminate\Support\Carbon::class); + $this->assertEquals($timestamp, $result->timestamp); + } + + public function testMapMultipleNumericTimestampsToCarbonResultsInCollectionOfCarbonInstances() + { + $timestamps = [1747939147, 1757939147]; + + $result = map($timestamps)->through(Collection::class)->to(Carbon::class); + + $this->assertTrue(get_class($result) === Collection::class); + $this->assertEquals(head($timestamps), $result->first()->timestamp); + $this->assertEquals(last($timestamps), $result->last()->timestamp); + } + + public function testMapMultipleNumericCsvTimestampsToCarbonResultsInCollectionOfCarbonInstances() + { + $timestamps = [1747939147, 1757939147]; + + $result = map(implode(',', $timestamps))->through(Collection::class)->to(Carbon::class); + + $this->assertTrue(get_class($result) === Collection::class); + $this->assertEquals(head($timestamps), $result->first()->timestamp); + $this->assertEquals(last($timestamps), $result->last()->timestamp); + } + + public function testMapArrayToGenericObjectResultsInStdClassInstance() + { + $input = ['hello' => 'world', 'foo' => 'bar']; + + $result = map($input)->to(stdClass::class); + + $this->assertTrue(get_class($result) === stdClass::class); + $this->assertEquals($input['hello'], $result->hello); + $this->assertEquals($input['foo'], $result->foo); + } + + public function testMapArraysAsArgsToGenericObjectResultsInCollectionOfStdClassInstances() + { + $firstObject = ['hello' => 'world', 'foo' => 'bar']; + $secondObject = ['one' => 'first', 'two' => 'second']; + + $result = map($firstObject, $secondObject)->through(Collection::class)->to(stdClass::class); + + $this->assertTrue(get_class($result) === Collection::class); + $this->assertEquals($firstObject['hello'], $result[0]->hello); + $this->assertEquals($secondObject['one'], $result[1]->one); + } + + public function testMapStringToBackedEnumResultInBackedEnumInstance() + { + $result = map('hidden')->to(PostStatus::class); + + $this->assertTrue(get_class($result) === PostStatus::class); + $this->assertTrue($result === PostStatus::Hidden); + } + + public function testMapStringsAsArgsToBackedEnumInCollectionOfBackedEnumInstances() + { + $result = map('hidden', 'published')->through(Collection::class)->to(PostStatus::class); + + $this->assertTrue(get_class($result) === Collection::class); + $this->assertTrue($result[0] === PostStatus::Hidden); + $this->assertTrue($result[1] === PostStatus::Published); + } } From 489c0e4cf831308476165f753ea3948e9a3da42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 5 Jun 2025 00:29:08 +0200 Subject: [PATCH 07/13] wip --- src/Attributes/WithDefaultValue.php | 14 -- src/Commands/DtoMakeCommand.php | 235 ------------------ src/Commands/DtoTypescriptGenerateCommand.php | 151 ----------- src/Commands/stubs/dto.request.plain.stub | 23 -- src/Commands/stubs/dto.request.stub | 24 -- src/Commands/stubs/dto.stub | 14 -- src/Contracts/MapeableObject.php | 10 + src/Mappers/MapeableObjectMapper.php | 25 ++ src/Mappers/ObjectDataMapper.php | 4 +- src/PropertyInfoExtractor.php | 31 +++ src/SerializableObject.php | 165 ------------ src/ServiceProvider.php | 1 + src/Support/TypeScript.php | 214 ++++++++++++++++ src/Support/ValidationRules.php | 140 +++++++++++ src/TypeGenerator.php | 228 ----------------- tests/Unit/DataTransferObjectTest.php | 3 +- tests/Unit/MapperTest.php | 12 + 17 files changed, 438 insertions(+), 856 deletions(-) delete mode 100644 src/Attributes/WithDefaultValue.php delete mode 100644 src/Commands/DtoMakeCommand.php delete mode 100644 src/Commands/DtoTypescriptGenerateCommand.php delete mode 100644 src/Commands/stubs/dto.request.plain.stub delete mode 100644 src/Commands/stubs/dto.request.stub delete mode 100644 src/Commands/stubs/dto.stub create mode 100644 src/Contracts/MapeableObject.php create mode 100644 src/Mappers/MapeableObjectMapper.php delete mode 100644 src/SerializableObject.php create mode 100644 src/Support/TypeScript.php create mode 100644 src/Support/ValidationRules.php delete mode 100644 src/TypeGenerator.php diff --git a/src/Attributes/WithDefaultValue.php b/src/Attributes/WithDefaultValue.php deleted file mode 100644 index 1ef6f72..0000000 --- a/src/Attributes/WithDefaultValue.php +++ /dev/null @@ -1,14 +0,0 @@ -openGeneratedAfter(fn () => parent::handle()); - } - - /** - * Get the stub file for the generator. - * - * @return string - */ - protected function getStub() - { - $stubSuffix = ''; - $requestOption = $this->option('request'); - - if ($requestOption !== false) { - $stubSuffix .= '.request'; - } - - if ($requestOption === null) { - $stubSuffix .= '.plain'; - } - - $stub = "/stubs/dto{$stubSuffix}.stub"; - - return file_exists($customPath = $this->laravel->basePath(trim($stub, '/'))) - ? $customPath - : __DIR__.$stub; - } - - /** - * Get the default namespace for the class. - * - * @param string $rootNamespace - * @return string - */ - protected function getDefaultNamespace($rootNamespace) - { - return $rootNamespace.'\DataTransferObjects'; - } - - /** - * Build the class with the given name. - * - * @param string $name - * @return string - * - * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException - */ - protected function buildClass($name) - { - $stub = parent::buildClass($name); - - $requestOption = $this->option('request'); - - if ($requestOption === null && $this->hasOption('request')) { - return $stub; - } - - return $this->replaceProperties($stub, $requestOption) - ->replaceRequestClass($stub, $requestOption); - } - - /** - * Replace the namespace for the given stub. - * - * @param string $stub - * @param string|null $requestClass - * @return $this - */ - protected function replaceProperties(&$stub, $requestClass) - { - $searches = [ - '{{ properties }}', - '{{properties}}', - ]; - - $properties = $requestClass ? $this->getProperties($requestClass) : '// '; - - foreach ($searches as $search) { - $stub = str_replace($search, $properties, $stub); - } - - return $this; - } - - /** - * Get the request properties for the given class. - * - * @return string - */ - protected function getProperties(string $requestClass) - { - if (! class_exists($requestClass)) { - return ''; - } - - $requestInstance = new $requestClass; - $properties = ''; - - $requestRules = $requestInstance->rules(); - $firstRequestRuleProperty = array_key_first($requestRules); - - // TODO: Sort nulls here to be prepended (need to create array first) - foreach ($requestRules as $property => $rules) { - if (str_contains($property, '.')) { - continue; - } - - $originalPropertyName = $property; - - if (str_ends_with($property, '_id')) { - $property = preg_replace('/_id$/', '', $property); - } - - $property = Str::camel($property); - - $rules = implode('|', is_array($rules) ? $rules : [$rules]); - - $propertyType = match (true) { - str_contains($rules, 'string') => 'string', - str_contains($rules, 'boolean') => 'bool', - str_contains($rules, 'numeric') => 'int', - str_contains($rules, 'integer') => 'int', - str_contains($rules, 'array') => 'array', - str_contains($rules, 'json') => '\stdClass', - str_contains($rules, 'date') => '\Illuminate\Support\Carbon', - default => 'string', - }; - - if (str_contains($rules, 'nullable')) { - $propertyType = "?{$propertyType}"; - } - - if ($firstRequestRuleProperty !== $originalPropertyName) { - $properties .= ",\n\t\t"; - } - - $properties .= "public {$propertyType} \${$property}"; - - if (str_contains($rules, 'nullable')) { - $properties .= ' = null'; - } - } - - return $properties; - } - - /** - * Replace the request class for the given stub. - * - * @param string $stub - * @param string|null $requestClass - * @return string - */ - public function replaceRequestClass(&$stub, $requestClass) - { - $returnRequestClass = '// '; - - if ($requestClass && class_exists($requestClass)) { - $returnRequestClass = 'return '; - $returnRequestClass .= (new \ReflectionClass($requestClass))->getShortName(); - $returnRequestClass .= '::class;'; - } - - $searches = [ - '{{ requestClass }}' => $requestClass, - '{{requestClass}}' => $requestClass, - '{{ returnRequestClass }}' => $returnRequestClass, - '{{returnRequestClass}}' => $returnRequestClass, - ]; - - foreach ($searches as $search => $replace) { - $stub = str_replace($search, $replace, $stub); - } - - return $stub; - } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the data transfer object already exists'], - ['request', 'r', InputOption::VALUE_OPTIONAL, 'Create the class implementing ValidatedDataTransferObject interface & request method', false], - ]; - } -} diff --git a/src/Commands/DtoTypescriptGenerateCommand.php b/src/Commands/DtoTypescriptGenerateCommand.php deleted file mode 100644 index 29e527b..0000000 --- a/src/Commands/DtoTypescriptGenerateCommand.php +++ /dev/null @@ -1,151 +0,0 @@ - $force, - 'output' => $outputDirectory, - 'source' => $sourceDirectory, - 'filename' => $outputFilename, - 'declarations' => $typesAsDeclarations, - ] = $this->getOptionsWithDefaults(); - - if (! $this->confirm('Are you sure you want to generate types from your data transfer objects?', $force)) { - return 1; - } - - if (! file_exists($sourceDirectory) || ! is_dir($sourceDirectory)) { - $this->error('Path does not exists'); - - return 2; - } - - if ( - (! file_exists($outputDirectory) || ! $this->filesystem->isWritable($outputDirectory)) - && ! $this->filesystem->makeDirectory($outputDirectory, 493, true) - ) { - $this->error('Permissions error, cannot create a directory under the destination path'); - - return 3; - } - - $namespace = App::getNamespace(); - $namespace .= str_replace(DIRECTORY_SEPARATOR, '\\', Str::replaceFirst(App::path('/'), '', $sourceDirectory)); - - $dataTransferObjects = Collection::make((new Finder)->files()->in($sourceDirectory)) - ->map(fn ($file) => implode('\\', [$namespace, $file->getBasename('.php')])) - ->sort() - ->filter(fn (string $className) => class_exists($className) && is_a($className, DataTransferObject::class, true)) - ->all(); - - $generatedTypesCollection = Collection::make([]); - - foreach ($dataTransferObjects as $dataTransferObject) { - (new TypeGenerator($dataTransferObject, $generatedTypesCollection))->generate(); - } - - $outputFilename .= $typesAsDeclarations ? '.d.ts' : '.ts'; - $outputFilePath = implode(DIRECTORY_SEPARATOR, [$outputDirectory, $outputFilename]); - - if ( - $this->filesystem->exists($outputFilePath) - && ! $this->confirm('Are you sure you want to overwrite the output file?', $force) - ) { - return 0; - } - - if (! $this->filesystem->put($outputFilePath, $generatedTypesCollection->join("\n\n"))) { - $this->error('Something happened and types file could not be written'); - - return 4; - } - - $this->info("Types file successfully generated at \"{$outputFilePath}\""); - - return 0; - } - - /** - * Get command options with defaults following an order. - */ - protected function getOptionsWithDefaults(): array - { - $options = array_merge( - $this->options(), - [ - 'output' => static::OPTION_DEFAULT_OUTPUT, - 'source' => static::OPTION_DEFAULT_SOURCE, - 'filename' => static::OPTION_DEFAULT_FILENAME, - ], - array_filter( - config('data-transfer-objects.types_generation', []), - fn ($configValue) => $configValue !== null - ) - ); - - $options['output'] = resource_path($options['output']); - $options['source'] = App::path($options['source']); - - return $options; - } - - /** - * Get the console command options. - * - * @return array - */ - protected function getOptions() - { - return [ - ['force', 'f', InputOption::VALUE_NONE, 'Force running replacing output files even if one exists'], - ['output', 'o', InputOption::VALUE_OPTIONAL, 'Destination folder where to place generated types (must be relative to resources folder). Default: '.static::OPTION_DEFAULT_OUTPUT], - ['source', 's', InputOption::VALUE_OPTIONAL, 'Source folder where to look at for data transfer objects (must be relative to app folder). Default: '.static::OPTION_DEFAULT_SOURCE], - ['filename', null, InputOption::VALUE_OPTIONAL, 'Destination file name without exception for the types generated. Default: '.static::OPTION_DEFAULT_FILENAME], - ['declarations', 'd', InputOption::VALUE_NONE, 'Generate types file as declarations (for e.g. types.d.ts instead of types.ts)'], - ]; - } -} diff --git a/src/Commands/stubs/dto.request.plain.stub b/src/Commands/stubs/dto.request.plain.stub deleted file mode 100644 index 63e403f..0000000 --- a/src/Commands/stubs/dto.request.plain.stub +++ /dev/null @@ -1,23 +0,0 @@ -objectClass, MapeableObject::class, true); + } + + /** + * Resolve mapper that runs once assert returns true. + */ + public function resolve(MappingValue $mappingValue): void + { + app($mappingValue->objectClass)->mappingFrom($mappingValue); + } +} diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 52de3d1..d1e1bc5 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -77,13 +77,15 @@ public function resolve(MappingValue $mappingValue): void continue; } + $unwrappedType = app(PropertyInfoExtractor::class)->unwrapType($type); + if ($type instanceof Type\NullableType) { $type = $type->getWrappedType(); } if ($type instanceof Type\CollectionType) { $data[$key] = map($value) - ->through((string) $type->getWrappedType()->getWrappedType()) + ->through((string) $unwrappedType) ->to($type->getCollectionValueType()); continue; diff --git a/src/PropertyInfoExtractor.php b/src/PropertyInfoExtractor.php index d7f6520..9c57bb6 100644 --- a/src/PropertyInfoExtractor.php +++ b/src/PropertyInfoExtractor.php @@ -2,6 +2,8 @@ namespace OpenSoutheners\LaravelDto; +use ReflectionClass; +use ReflectionProperty; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor as Extractor; @@ -26,4 +28,33 @@ public function typeInfo(string $class, string $property, array $context = []): { return $this->extractor->getType($class, $property, $context); } + + /** + * @return array + */ + public function typeInfoFromClass(string $class, array $context = []): array + { + $classReflection = new ReflectionClass($class); + + $classProperties = $classReflection->getProperties(ReflectionProperty::IS_PUBLIC); + + $propertiesTypes = []; + + foreach ($classProperties as $property) { + $propertiesTypes[$property->getName()] = $this->extractor->getType($class, $property->getName(), $context); + } + + return $propertiesTypes; + } + + public function unwrapType(Type $type): Type + { + $builtinType = $type; + + while (method_exists($builtinType, 'getWrappedType')) { + $builtinType = $builtinType->getWrappedType(); + } + + return $builtinType; + } } diff --git a/src/SerializableObject.php b/src/SerializableObject.php deleted file mode 100644 index 436c50b..0000000 --- a/src/SerializableObject.php +++ /dev/null @@ -1,165 +0,0 @@ -get('dto.context.booted') === static::class && $request->route()) { - $requestHasProperty = $request->has(Str::snake($property)) - ?: $request->has($property) - ?: $request->has($camelProperty); - - if (! $requestHasProperty && $request->route() instanceof Route) { - return $request->route()->hasParameter($property) - ?: $request->route()->hasParameter($camelProperty); - } - - return $requestHasProperty; - } - - $reflection = new \ReflectionClass($this); - - $classProperty = match (true) { - $reflection->hasProperty($property) => $property, - $reflection->hasProperty($camelProperty) => $camelProperty, - default => throw new Exception("Properties '{$property}' or '{$camelProperty}' doesn't exists on class instance."), - }; - - [$classPropertyTypes] = ObjectMapper::getPropertiesInfoFrom(get_class($this), $classProperty); - - $reflectionProperty = $reflection->getProperty($classProperty); - $propertyValue = $reflectionProperty->getValue($this); - - if ($classPropertyTypes === null) { - return function_exists('filled') && filled($propertyValue); - } - - $propertyDefaultValue = $reflectionProperty->getDefaultValue(); - - $propertyIsNullable = in_array(true, array_map(fn (Type $type) => $type->isNullable(), $classPropertyTypes), true); - - /** - * Not filled when DTO property's default value is set to null while none is passed through - */ - if (! $propertyValue && $propertyIsNullable && $propertyDefaultValue === null) { - return false; - } - - /** - * Not filled when property isn't promoted and does have a default value matching value sent - * - * @see problem with promoted properties and hasDefaultValue/getDefaultValue https://bugs.php.net/bug.php?id=81386 - */ - if (! $reflectionProperty->isPromoted() && $reflectionProperty->hasDefaultValue() && $propertyValue === $propertyDefaultValue) { - return false; - } - - return true; - } - - /** - * Get the instance as an array. - * - * @return array - */ - public function toArray() - { - /** @var array<\ReflectionProperty> $properties */ - $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); - $newPropertiesArr = []; - - foreach ($properties as $property) { - if (! $this->filled($property->name) && count($property->getAttributes(WithDefaultValue::class)) === 0) { - continue; - } - - $propertyValue = $property->getValue($this) ?? $property->getDefaultValue(); - - if ($propertyValue instanceof Arrayable) { - $propertyValue = $propertyValue->toArray(); - } - - if ($propertyValue instanceof \stdClass) { - $propertyValue = (array) $propertyValue; - } - - $newPropertiesArr[Str::snake($property->name)] = $propertyValue; - } - - return $newPropertiesArr; - } - - public function __serialize(): array - { - $reflection = new \ReflectionClass($this); - - /** @var array<\ReflectionProperty> $properties */ - $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); - - $serialisableArr = []; - - foreach ($properties as $property) { - $key = $property->getName(); - $value = $property->getValue($this); - - /** @var array<\ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\BindModel>> $propertyModelBindingAttribute */ - $propertyModelBindingAttribute = $property->getAttributes(BindModel::class); - $propertyModelBindingAttribute = reset($propertyModelBindingAttribute); - - $propertyModelBindingAttributeName = null; - - if ($propertyModelBindingAttribute) { - $propertyModelBindingAttributeName = $propertyModelBindingAttribute->newInstance()->using; - } - - $serialisableArr[$key] = match (true) { - $value instanceof Model => $value->getAttribute($propertyModelBindingAttributeName ?? $value->getRouteKeyName()), - $value instanceof Collection => $value->first() instanceof Model ? $value->map(fn (Model $model) => $model->getAttribute($propertyModelBindingAttributeName ?? $model->getRouteKeyName()))->join(',') : $value->join(','), - $value instanceof Arrayable => $value->toArray(), - $value instanceof \Stringable => (string) $value, - is_array($value) => head($value) instanceof Model ? implode(',', array_map(fn (Model $model) => $model->getAttribute($propertyModelBindingAttributeName ?? $model->getRouteKeyName()), $value)) : implode(',', $value), - default => $value, - }; - } - - return $serialisableArr; - } - - /** - * Called during unserialization of the object. - */ - public function __unserialize(array $data): void - { - $properties = (new \ReflectionClass($this))->getProperties(\ReflectionProperty::IS_PUBLIC); - - $propertiesMapper = new ObjectMapper(array_merge($data), static::class); - - $data = $propertiesMapper->run(); - - foreach ($properties as $property) { - $key = $property->getName(); - - $this->{$key} = $data[$key] ?? $property->getDefaultValue(); - } - } -} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b4af58b..67afe84 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -13,6 +13,7 @@ class ServiceProvider extends BaseServiceProvider { protected static $mappers = [ + Mappers\MapeableObjectMapper::class, Mappers\CollectionDataMapper::class, Mappers\ModelDataMapper::class, diff --git a/src/Support/TypeScript.php b/src/Support/TypeScript.php new file mode 100644 index 0000000..0ec8ec8 --- /dev/null +++ b/src/Support/TypeScript.php @@ -0,0 +1,214 @@ +script as $exportName => $types) { + $exportType = $this->exportTypes[$exportName] ?? 'type'; + + $result .= "export {$exportType} {$exportName} "; + + if ($exportType === 'type') { + $result .= "= "; + } + + $result .= is_string($types) ? $types : ("{\n".implode(",\n", $types).",\n};\n"); + $result .= "\n"; + } + + return $result; + } + + public function mappingFrom(MappingValue $mappingValue): void + { + $mappingValue->data = $this->fromClass($mappingValue->data); + } + + public function fromClass(string $class): self + { + if (is_a($class, Model::class, true)) { + $this->fromModelObject($class); + + return $this; + } + + if (is_enum($class)) { + $this->fromEnum($class); + + return $this; + } + + $properties = app(PropertyInfoExtractor::class)->typeInfoFromClass($class); + + $typeName = $this->typeName($class); + $this->script[$typeName] = []; + + foreach ($properties as $propertyName => $type) { + $this->script[$typeName][] = "{$propertyName}: {$this->fromType($type)}"; + } + + return $this; + } + + private function typeName(string $class): string + { + return class_basename($class); + } + + private function fromModelObject(string $class) + { + $columns = Schema::getColumns((new $class)->getTable()); + + $typeName = $this->typeName($class); + + if (isset($this->script[$typeName])) { + return $typeName; + } + + $this->script[$typeName] = []; + + foreach ($columns as $column) { + $type = match ($column['type_name']) { + 'int2' => 'number', + 'int4' => 'number', + 'int8' => 'number', + 'smallint' => 'number', + 'bigserial' => 'number', + 'serial' => 'number', + 'boolean' => 'bool', + 'float' => 'number', + 'double' => 'number', + 'decimal' => 'number', + 'numeric' => 'string', + 'varchar' => 'string', + 'text' => 'string', + 'date' => 'string', + 'timestamp' => 'string', + 'timestamptz' => 'string', + 'time' => 'string', + 'interval' => 'string', + 'json' => 'string', + 'jsonb' => 'string', + 'bytea' => 'string', + 'oid' => 'number', + 'cidr' => 'string', + 'inet' => 'string', + 'macaddr' => 'string', + 'uuid' => 'string', + default => 'any', + }; + + $type .= $column['nullable'] ? ' | null' : ''; + + $this->script[$typeName][] = "{$column['name']}: {$type}"; + } + } + + private function fromType(Type $type): string + { + return match (true) { + $type instanceof Type\UnionType => $this->fromUnionType($type), + $type instanceof Type\CollectionType => $this->fromCollectionType($type), + $type instanceof Type\ObjectType => $this->fromObjectType($type), + $type instanceof Type\BuiltinType => $this->fromBuiltinType($type), + $type instanceof Type\EnumType => $this->fromClass($type->getClassName()), + default => 'any', + }; + } + + private function fromCollectionType(Type\CollectionType $type): string + { + $collectionKeyType = $this->fromType($type->getCollectionKeyType()); + $collectionValueType = $type->getCollectionValueType(); + $collectionValueType = $this->fromType($collectionValueType instanceof Type\CollectionType ? $collectionValueType->getWrappedType() : $collectionValueType); + + if ($collectionKeyType === 'int | string') { + return "Array<{$collectionValueType}>"; + } + + return "Record<{$collectionKeyType}, {$collectionValueType}>"; + } + + private function fromUnionType(Type\UnionType $type): string + { + $types = array_map(fn(Type $childrenType) => $this->fromType($childrenType), $type->getTypes()); + + return implode(' | ', $types); + } + + /** + * @param class-string $class + */ + private function fromEnum(string $class): void + { + if (!enum_is_backed($class)) { + throw new \Exception('Non backed enums are not supported'); + } + + $typeName = $this->typeName($class); + + $this->script[$typeName] = []; + $this->exportTypes[$typeName] = 'enum'; + + foreach ($class::cases() as $case) { + $this->script[$typeName][] = "{$case->name} = {$case->value}"; + } + } + + private function fromObjectType(Type\ObjectType $type): string + { + $class = $type->getClassName(); + + if (is_a($class, Collection::class, true)) { + return 'Array'; + } + + $this->fromClass($class); + + return array_key_last($this->script); + } + + private function fromBuiltinType(Type\BuiltinType $type): string + { + return match ($type->getTypeIdentifier()) { + TypeIdentifier::ARRAY => 'Array', + TypeIdentifier::BOOL => 'boolean', + // TODO: Remove callable + TypeIdentifier::CALLABLE => 'unknown', + TypeIdentifier::FLOAT => 'number', + TypeIdentifier::INT => 'number', + TypeIdentifier::MIXED => 'unknown', + TypeIdentifier::NULL => 'null', + TypeIdentifier::OBJECT => 'object', + TypeIdentifier::STRING => 'string', + TypeIdentifier::VOID => 'undefined', + TypeIdentifier::NEVER => 'never', + }; + } +} diff --git a/src/Support/ValidationRules.php b/src/Support/ValidationRules.php new file mode 100644 index 0000000..d5c4125 --- /dev/null +++ b/src/Support/ValidationRules.php @@ -0,0 +1,140 @@ +class = new ReflectionClass($class); + + $properties = app(PropertyInfoExtractor::class)->typeInfoFromClass($class); + + foreach ($properties as $name => $type) { + $this->rules[$name] = $this->getRulesForProperty($name, $type); + } + + return $this; + } + + public function mappingFrom(MappingValue $mappingValue): void + { + $mappingValue->data = $this->fromClass($mappingValue->data); + } + + public function toArray(): array + { + return $this->rules; + } + + public function getRulesForProperty(string $name, Type $type): array + { + $rules = $this->fromType($type); + + $reflectionProperty = $this->class->getProperty($name); + + $attributes = $reflectionProperty->getAttributes(); + + $containerAttributes = array_filter($attributes, fn(ReflectionAttribute $attribute) => in_array($attribute->getName(), [Authenticated::class, Inject::class])); + + if ($reflectionProperty->hasDefaultValue() || count($containerAttributes) > 0) { + $rules[] = 'nullable'; + } + + return array_unique($rules); + } + + private function fromType(Type $type): array + { + return match (true) { + $type instanceof Type\BuiltinType => $this->fromBuiltinType($type), + $type instanceof Type\UnionType => $this->fromUnionType($type), + $type instanceof Type\CollectionType => $this->fromCollectionType($type), + $type instanceof Type\EnumType => $this->fromEnumType($type), + $type instanceof Type\ObjectType => $this->fromObjectType($type), + default => [], + }; + } + + private function fromEnumType(Type\EnumType $type): array + { + return [Rule::enum($type->getClassName())]; + } + + private function fromObjectType(Type\ObjectType $type): array + { + $typeClass = $type->getClassName(); + + return match (true) { + is_a($typeClass, Model::class, true) => ['string', 'numeric'], + default => [], + }; + } + + private function fromCollectionType(Type\CollectionType $type): array + { + $valueType = $type->getCollectionValueType(); + + if ($valueType instanceof Type\CollectionType) { + return $this->fromType($valueType->getWrappedType()); + } + + return []; + } + + private function fromUnionType(Type\UnionType $type): array + { + $rules = ['nullable']; + + return array_merge($rules, $this->fromType($type->getTypes()[0])); + } + + private function fromBuiltinType(Type\BuiltinType $type): array + { + return match ($type->getTypeIdentifier()) { + TypeIdentifier::STRING => ['string'], + TypeIdentifier::INT => ['integer'], + TypeIdentifier::FLOAT => ['numeric'], + TypeIdentifier::BOOL => ['boolean'], + default => [], + }; + } + + public function offsetExists($offset): bool + { + return isset($this->rules[$offset]); + } + + public function offsetGet($offset): mixed + { + return $this->rules[$offset]; + } + + public function offsetSet($offset, $value): void + { + $this->rules[$offset] = $value; + } + + public function offsetUnset($offset): void + { + unset($this->rules[$offset]); + } +} diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php deleted file mode 100644 index e85c50b..0000000 --- a/src/TypeGenerator.php +++ /dev/null @@ -1,228 +0,0 @@ - - */ - public const PHP_TO_TYPESCRIPT_VARIANT_TYPES = [ - 'int' => 'number', - 'float' => 'number', - 'bool' => 'boolean', - '\stdClass' => 'Record', - ]; - - public function __construct( - protected string $dataTransferObject, - protected Collection $generatedTypes - ) { - // - } - - /** - * Generate TypeScript types from sent data transfer object. - */ - public function generate(): void - { - $reflection = new ReflectionClass($this->dataTransferObject); - - /** - * Only needed when non-typed properties are found to compare with isOptional - * on the parameter reflector. - * - * @var array<\ReflectionParameter> $constructorParameters - */ - $constructorParameters = $reflection->getConstructor() ? $reflection->getConstructor()->getParameters() : []; - - $normalisesPropertiesKeys = config('data-transfer-objects.normalise_properties', true); - - if (! empty($reflection->getAttributes(NormaliseProperties::class))) { - $normalisesPropertiesKeys = true; - } - - $properties = ObjectMapper::getPropertiesInfoFrom($this->dataTransferObject); - - $exportedType = $this->getExportTypeName($reflection); - $exportAsString = "export type {$exportedType} = {\n"; - - foreach ($properties as $propertyName => $propertyTypes) { - $propertyType = reset($propertyTypes); - - $propertyTypeClass = $propertyType ? $propertyType->getClassName() : null; - - if (is_a($propertyTypeClass, Authenticatable::class, true)) { - continue; - } - - $propertyTypeAsString = $this->extractTypeFromPropertyType($propertyType); - - if ($normalisesPropertiesKeys) { - $propertyKeyAsString = Str::snake($propertyName); - $propertyKeyAsString .= is_subclass_of($propertyTypeClass, Model::class) ? '_id' : ''; - } - - $nullMark = $this->isNullableProperty( - $propertyType, - $propertyName, - $constructorParameters - ) ? '?' : ''; - - $exportAsString .= "\t{$propertyKeyAsString}{$nullMark}: {$propertyTypeAsString};\n"; - } - - $exportAsString .= '};'; - - $this->generatedTypes[$exportedType] = $exportAsString; - } - - /** - * Determine whether the specified property is nullable. - * - * @param array<\ReflectionParameter> $constructorParameters - */ - protected function isNullableProperty(Type|false $propertyType, string $propertyName, array $constructorParameters): bool - { - $constructorParameter = array_filter( - $constructorParameters, - fn (\ReflectionParameter $param) => $param->getName() === $propertyName - ); - - $constructorParameter = reset($constructorParameter); - - if ($constructorParameter && count($constructorParameter->getAttributes(WithDefaultValue::class)) > 0) { - return true; - } - - if ($propertyType) { - return $propertyType->isNullable(); - } - - return $constructorParameter->isOptional(); - } - - /** - * Get custom export type name if customised from attribute otherwise use class name. - */ - protected function getExportTypeName(ReflectionClass $reflection): string - { - /** @var array<\ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\AsType>> $classAttributes */ - $classAttributes = $reflection->getAttributes(AsType::class); - - $classAttribute = reset($classAttributes); - - if (! $classAttribute) { - return $reflection->getShortName(); - } - - return $classAttribute->newInstance()->typeName; - } - - /** - * Extract TypeScript types from PHP property type. - */ - protected function extractTypeFromPropertyType(Type|false $propertyType): string - { - if (! $propertyType) { - return 'unknown'; - } - - $propertyBuiltInType = $propertyType->getBuiltinType(); - $propertyTypeString = $propertyType->getClassName() ?? $propertyBuiltInType; - - return match (true) { - $propertyType->isCollection() || is_a($propertyTypeString, Collection::class, true) => $this->extractCollectionType($propertyTypeString, $propertyType->getCollectionValueTypes()), - is_a($propertyTypeString, Model::class, true) => $this->extractModelType($propertyTypeString), - is_a($propertyTypeString, \BackedEnum::class, true) => $this->extractEnumType($propertyTypeString), - $propertyBuiltInType === 'object' && $propertyBuiltInType !== $propertyTypeString => $this->extractObjectType($propertyTypeString), - default => $this->builtInTypeToTypeScript($propertyType->getBuiltinType()), - }; - } - - /** - * Handle conversion between native PHP and JavaScript types. - */ - protected function builtInTypeToTypeScript(string $identifier): string - { - return static::PHP_TO_TYPESCRIPT_VARIANT_TYPES[$identifier] ?? $identifier; - } - - /** - * Generate types from non-generic object. - */ - protected function extractObjectType(string $objectClass): string - { - (new self($objectClass, $this->generatedTypes))->generate(); - - return class_basename($objectClass); - } - - /** - * Generate types from PHP native enum. - * - * @see https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums - */ - protected function extractEnumType(string $enumClass): string - { - $exportedType = class_basename($enumClass); - - if ($this->generatedTypes->has($exportedType)) { - return $exportedType; - } - - $exportsAsString = ''; - $exportsAsString .= "export const enum {$exportedType} {\n"; - - foreach ($enumClass::cases() as $case) { - $caseValueAsString = is_int($case->value) ? $case->value : "\"{$case->value}\""; - $exportsAsString .= "\t{$case->name} = {$caseValueAsString},\n"; - } - - $exportsAsString .= '};'; - - $this->generatedTypes[$exportedType] = $exportsAsString; - - return $exportedType; - } - - /** - * Generate types from collection. - * - * @param array<\Symfony\Component\PropertyInfo\Type> $collectedTypes - */ - protected function extractCollectionType(string $collection, array $collectedTypes): string - { - $collectedType = reset($collectedTypes); - - if (! $collectedType) { - return 'Array'; - } - - return $this->extractTypeFromPropertyType($collectedType); - } - - /** - * Generate types from Eloquent models bindings. - * - * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass - */ - protected function extractModelType(string $modelClass): string - { - // TODO: Check type from Model's property's attribute or getRouteKeyName as fallback - // TODO: To be able to do the above need to generate types from models - return 'string'; - } -} diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index 446eabc..b262dee 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\Tests\Integration\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; use Workbench\App\DataTransferObjects\CreatePostData; @@ -11,7 +12,7 @@ use function OpenSoutheners\LaravelDto\map; -class DataTransferObjectTest extends UnitTestCase +class DataTransferObjectTest extends TestCase { public function test_data_transfer_object_from_array() { diff --git a/tests/Unit/MapperTest.php b/tests/Unit/MapperTest.php index 81ef387..528e1f5 100644 --- a/tests/Unit/MapperTest.php +++ b/tests/Unit/MapperTest.php @@ -5,8 +5,10 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection as DatabaseCollection; use Illuminate\Support\Collection; +use OpenSoutheners\LaravelDto\Support\TypeScript; use OpenSoutheners\LaravelDto\Tests\Integration\TestCase; use stdClass; +use Workbench\App\DataTransferObjects\UpdatePostWithDefaultData; use Workbench\App\Enums\PostStatus; use Workbench\App\Models\User; use Workbench\Database\Factories\UserFactory; @@ -128,4 +130,14 @@ public function testMapStringsAsArgsToBackedEnumInCollectionOfBackedEnumInstance $this->assertTrue($result[0] === PostStatus::Hidden); $this->assertTrue($result[1] === PostStatus::Published); } + + public function testMapObjectToTypeScriptResultsInStringifiedScriptCode() + { + $result = (string) map(UpdatePostWithDefaultData::class)->to(TypeScript::class); + + $this->assertIsString($result); + $this->assertStringContainsString('post: Post,', $result); + $this->assertStringContainsString('author: User,', $result); + $this->assertStringContainsString('parent: Post | Tag | null,', $result); + } } From 6d7e89241587029f6f2d47af6586c87e61bc573c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 5 Jun 2025 00:55:09 +0200 Subject: [PATCH 08/13] wip --- CHANGELOG.md | 6 +- ...a-transfer-objects.php => data-mapper.php} | 0 src/Mappers/ObjectDataMapper.php | 2 +- src/ServiceProvider.php | 6 +- src/Support/TypeScript.php | 12 ++++ tests/Integration/DataTransferObjectTest.php | 2 +- tests/Integration/DtoMakeCommandTest.php | 57 ---------------- .../DtoTypescriptGenerateCommandTest.php | 65 ------------------- tests/Integration/TestCase.php | 2 +- 9 files changed, 22 insertions(+), 130 deletions(-) rename config/{data-transfer-objects.php => data-mapper.php} (100%) delete mode 100644 tests/Integration/DtoMakeCommandTest.php delete mode 100644 tests/Integration/DtoTypescriptGenerateCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c80a19..8f4e853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,23 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [4.0.0] - 2025-05-20 +## [4.0.0] - 2025-06-08 ### Added - Attribute `OpenSoutheners\LaravelDto\Attributes\Inject` to inject container stuff - Attribute `OpenSoutheners\LaravelDto\Attributes\Authenticated` that uses base `Illuminate\Container\Attributes\Authenticated` to inject current authenticated user - Ability to register custom mappers (extending package functionality) +- `OpenSoutheners\LaravelDto\Contracts\MapeableObject` interface to add custom mapping logic to your own objects classes - ObjectMapper now extracts type info from generics inside collections typed properties [#1] ### Changed +- Package renamed to `open-southeners/laravel-data-mapper` +- Config file changed and renamed to `config/data-mapper.php` (publish the new one using `php artisan vendor:publish --tag="laravel-data-mapper"`) - Full refactor [#7] ### Removed - Abstract class `OpenSoutheners\LaravelDto\DataTransferObject` (using POPO which means _Plain Old Php Objects_) - Attribute `OpenSoutheners\LaravelDto\Attributes\WithDefaultValue` (when using with `Illuminate\Contracts\Auth\Authenticatable` can be replaced by `OpenSoutheners\LaravelDto\Attributes\Authenticated`) +- Artisan commands: `make:dto`, `dto:typescript` ## [3.7.0] - 2025-03-04 diff --git a/config/data-transfer-objects.php b/config/data-mapper.php similarity index 100% rename from config/data-transfer-objects.php rename to config/data-mapper.php diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index d1e1bc5..847bb0e 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -108,7 +108,7 @@ protected function normalisePropertyKey(MappingValue $mappingValue, string $key) $class = new ReflectionClass($mappingValue->objectClass); $normaliseProperty = count($class->getAttributes(NormaliseProperties::class)) > 0 - ?: (app('config')->get('data-transfer-objects.normalise_properties') ?? true); + ?: (app('config')->get('data-mapper.normalise_properties') ?? true); if (! $normaliseProperty) { return $key; diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 67afe84..ce7943f 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -32,10 +32,8 @@ public function boot() { if ($this->app->runningInConsole()) { $this->publishes([ - __DIR__.'/../config/data-transfer-objects.php' => config_path('data-transfer-objects.php'), - ], 'config'); - - $this->commands([DtoMakeCommand::class, DtoTypescriptGenerateCommand::class]); + __DIR__.'/../config/data-mapper.php' => config_path('data-mapper.php'), + ], ['config', 'laravel-data-mapper']); } $this->app->beforeResolving( diff --git a/src/Support/TypeScript.php b/src/Support/TypeScript.php index 0ec8ec8..831939c 100644 --- a/src/Support/TypeScript.php +++ b/src/Support/TypeScript.php @@ -6,9 +6,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Schema; +use OpenSoutheners\LaravelDto\Attributes\AsType; use OpenSoutheners\LaravelDto\Contracts\MapeableObject; use OpenSoutheners\LaravelDto\DataTransferObjects\MappingValue; use OpenSoutheners\LaravelDto\PropertyInfoExtractor; +use ReflectionClass; use Stringable; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -78,6 +80,16 @@ public function fromClass(string $class): self private function typeName(string $class): string { + $reflectionClass = new ReflectionClass($class); + + $attributes = $reflectionClass->getAttributes(AsType::class); + + $asTypeAttribute = reset($attributes); + + if ($asTypeAttribute) { + return $asTypeAttribute->newInstance()->typeName; + } + return class_basename($class); } diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/Integration/DataTransferObjectTest.php index dda2827..2c92928 100644 --- a/tests/Integration/DataTransferObjectTest.php +++ b/tests/Integration/DataTransferObjectTest.php @@ -114,7 +114,7 @@ public function test_data_transfer_object_filled_via_request() public function test_data_transfer_object_without_property_keys_normalisation_when_disabled_from_config() { - config(['data-transfer-objects.normalise_properties' => false]); + config(['data-mapper.normalise_properties' => false]); $post = Post::create([ 'id' => 2, diff --git a/tests/Integration/DtoMakeCommandTest.php b/tests/Integration/DtoMakeCommandTest.php deleted file mode 100644 index 1cc884f..0000000 --- a/tests/Integration/DtoMakeCommandTest.php +++ /dev/null @@ -1,57 +0,0 @@ -artisan('make:dto', ['name' => 'CreatePostData']) - ->assertExitCode(0); - - $this->assertFileContains([ - 'namespace App\DataTransferObjects;', - 'final class CreatePostData', - ], 'app/DataTransferObjects/CreatePostData.php'); - } - - public function test_make_data_transfer_object_command_with_empty_request_option_creates_the_file_with_validated_request() - { - $this->artisan('make:dto', [ - 'name' => 'CreatePostData', - '--request' => true, - ])->assertExitCode(0); - - $this->assertFileContains([ - 'namespace App\DataTransferObjects;', - 'final class CreatePostData', - 'implements ValidatedDataTransferObject', - 'public static function request(): string', - ], 'app/DataTransferObjects/CreatePostData.php'); - } - - // TODO: Test properties from rules population - public function test_make_data_transfer_object_command_with_request_option_creates_the_file_with_properties() - { - $this->artisan('make:dto', [ - 'name' => 'CreatePostData', - '--request' => 'Workbench\App\Http\Requests\PostCreateFormRequest', - ])->assertExitCode(0); - - $this->assertFileContains([ - 'namespace App\DataTransferObjects;', - 'use Workbench\App\Http\Requests\PostCreateFormRequest;', - 'final class CreatePostData', - 'implements ValidatedDataTransferObject', - 'return PostCreateFormRequest::class;', - ], 'app/DataTransferObjects/CreatePostData.php'); - } -} diff --git a/tests/Integration/DtoTypescriptGenerateCommandTest.php b/tests/Integration/DtoTypescriptGenerateCommandTest.php deleted file mode 100644 index 3fd7074..0000000 --- a/tests/Integration/DtoTypescriptGenerateCommandTest.php +++ /dev/null @@ -1,65 +0,0 @@ - [ - 'output' => 'js', - // 'source' => 'tests/Fixtures', - 'filename' => 'types', - 'declarations' => false, - ], - ]); - } - - public function test_dto_typescript_generates_typescript_types_file() - { - App::shouldReceive('getNamespace') - ->once() - ->andReturn('Workbench\App'); - - App::shouldReceive('path') - ->once() - ->withArgs(['DataTransferObjects']) - ->andReturn(workbench_path('app/DataTransferObjects')); - - App::shouldReceive('path') - ->once() - ->withArgs(['/']) - ->andReturn(workbench_path('app')); - - $command = $this->artisan('dto:typescript'); - - $command->expectsConfirmation('Are you sure you want to generate types from your data transfer objects?', 'yes'); - - $command->expectsOutput('Types file successfully generated at "'.resource_path('js/types.ts').'"'); - - $exitCode = $command->run(); - - $this->assertEquals(0, $exitCode); - - $this->assertFileContains([ - 'export type UpdatePostData', - 'export type CreatePostData', - 'export type UpdatePostFormData', - "export type UpdatePostWithDefaultData = {\n\tpost_id?: string;\n\t", - ], 'resources/js/types.ts'); - } -} diff --git a/tests/Integration/TestCase.php b/tests/Integration/TestCase.php index 074ff8a..eccf2bd 100644 --- a/tests/Integration/TestCase.php +++ b/tests/Integration/TestCase.php @@ -37,6 +37,6 @@ protected function defineEnvironment($app) 'prefix' => '', ]); - $app['config']->set('data-transfer-objects', include_once __DIR__.'/../../config/data-transfer-objects.php'); + $app['config']->set('data-mapper', include_once __DIR__.'/../../config/data-mapper.php'); } } From 86d9dfb012cb64b6ecab2f2452b1344bd0099a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 5 Jun 2025 01:23:30 +0200 Subject: [PATCH 09/13] wip --- composer.json | 8 +++--- src/Attributes/AsType.php | 2 +- src/Attributes/Authenticated.php | 2 +- src/Attributes/Inject.php | 2 +- src/Attributes/ModelWith.php | 2 +- src/Attributes/NormaliseProperties.php | 2 +- src/Attributes/ResolveModel.php | 2 +- src/Attributes/Validate.php | 2 +- src/Contracts/MapeableObject.php | 4 +-- src/Contracts/RouteTransferableObject.php | 2 +- src/Mapper.php | 6 ++--- src/Mappers/BackedEnumDataMapper.php | 4 +-- src/Mappers/CarbonDataMapper.php | 4 +-- src/Mappers/CollectionDataMapper.php | 6 ++--- src/Mappers/DataMapper.php | 4 +-- src/Mappers/GenericObjectDataMapper.php | 4 +-- src/Mappers/MapeableObjectMapper.php | 6 ++--- src/Mappers/ModelDataMapper.php | 17 +++++------- src/Mappers/ObjectDataMapper.php | 10 +++---- .../MappingValue.php | 2 +- src/PropertyInfoExtractor.php | 2 +- src/ServiceProvider.php | 8 +++--- src/Support/TypeScript.php | 10 +++---- src/Support/ValidationRules.php | 12 ++++----- src/functions.php | 2 +- testbench.yaml | 26 +++++++++---------- tests/Integration/DataTransferObjectTest.php | 4 +-- tests/Integration/TestCase.php | 2 +- .../ValidatedDataTransferObjectTest.php | 4 +-- tests/Unit/DataTransferObjectTest.php | 6 ++--- tests/Unit/MapperTest.php | 8 +++--- tests/Unit/UnitTestCase.php | 6 ++--- .../DataTransferObjects/CreatePostData.php | 4 +-- .../UpdatePostWithDefaultData.php | 6 ++--- .../UpdatePostWithRouteBindingData.php | 10 +++---- .../app/DataTransferObjects/UpdateTagData.php | 8 +++--- 36 files changed, 101 insertions(+), 108 deletions(-) rename src/{DataTransferObjects => }/MappingValue.php (95%) diff --git a/composer.json b/composer.json index b0ee01d..410c424 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "open-southeners/laravel-dto", + "name": "open-southeners/laravel-data-mapper", "description": "Integrate data transfer objects into Laravel, the easiest way", "license": "MIT", "keywords": [ @@ -43,7 +43,7 @@ "prefer-stable": true, "autoload": { "psr-4": { - "OpenSoutheners\\LaravelDto\\": "src" + "OpenSoutheners\\LaravelDataMapper\\": "src" }, "files": [ "src/functions.php" @@ -51,7 +51,7 @@ }, "autoload-dev": { "psr-4": { - "OpenSoutheners\\LaravelDto\\Tests\\": "tests", + "OpenSoutheners\\LaravelDataMapper\\Tests\\": "tests", "Workbench\\App\\": "workbench/app/", "Workbench\\Database\\Factories\\": "workbench/database/factories/", "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" @@ -63,7 +63,7 @@ "extra": { "laravel": { "providers": [ - "OpenSoutheners\\LaravelDto\\ServiceProvider" + "OpenSoutheners\\LaravelDataMapper\\ServiceProvider" ] } }, diff --git a/src/Attributes/AsType.php b/src/Attributes/AsType.php index 76f74d4..c6ca2e9 100644 --- a/src/Attributes/AsType.php +++ b/src/Attributes/AsType.php @@ -1,6 +1,6 @@ property->getAttributes(ResolveModel::class); - // /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDto\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ + // /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ // $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); - // /** @var \OpenSoutheners\LaravelDto\Attributes\ResolveModel|null $resolveModelAttribute */ + // /** @var \OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel|null $resolveModelAttribute */ // $resolveModelAttribute = $resolveModelAttributeReflector // ? $resolveModelAttributeReflector->newInstance() // : new ResolveModel(morphTypeFrom: ResolveModel::getDefaultMorphKeyFrom($mappingValue->property->getName())); diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 847bb0e..0470fdc 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -1,14 +1,14 @@ Date: Thu, 5 Jun 2025 01:43:20 +0200 Subject: [PATCH 10/13] package rename --- README.md | 8 ++++---- composer.json | 4 +++- src/ServiceProvider.php | 21 --------------------- 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a2b5938..f33c47e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ -Laravel DTO [![required php version](https://img.shields.io/packagist/php-v/open-southeners/laravel-dto)](https://www.php.net/supported-versions.php) [![run-tests](https://github.com/open-southeners/laravel-dto/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/open-southeners/laravel-dto/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/open-southeners/laravel-dto/branch/main/graph/badge.svg?token=LjNbU4Sp2Z)](https://codecov.io/gh/open-southeners/laravel-dto) [![Edit on VSCode online](https://img.shields.io/badge/vscode-edit%20online-blue?logo=visualstudiocode)](https://vscode.dev/github/open-southeners/laravel-dto) +Laravel Data Mapper [![required php version](https://img.shields.io/packagist/php-v/open-southeners/laravel-data-mapper)](https://www.php.net/supported-versions.php) [![run-tests](https://github.com/open-southeners/laravel-data-mapper/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/open-southeners/laravel-data-mapper/actions/workflows/tests.yml) [![codecov](https://codecov.io/gh/open-southeners/laravel-data-mapper/branch/main/graph/badge.svg?token=LjNbU4Sp2Z)](https://codecov.io/gh/open-southeners/laravel-data-mapper) [![Edit on VSCode online](https://img.shields.io/badge/vscode-edit%20online-blue?logo=visualstudiocode)](https://vscode.dev/github/open-southeners/laravel-data-mapper) === -Integrate data transfer objects into Laravel, the easiest way +Extensible data mapper to objects, DTOs, enums, collections, Eloquent models, etc ## Getting started ``` -composer require open-southeners/laravel-dto +composer require open-southeners/laravel-data-mapper ``` ## Documentation -[Official documentation](https://docs.opensoutheners.com/laravel-dto/) +[Official documentation](https://docs.opensoutheners.com/laravel-data-mapper/) ## Partners diff --git a/composer.json b/composer.json index 410c424..04a8ef2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "open-southeners/laravel-data-mapper", - "description": "Integrate data transfer objects into Laravel, the easiest way", + "description": "Extensible data mapper to objects, DTOs, enums, collections, Eloquent models, etc", "license": "MIT", "keywords": [ "open-southeners", @@ -8,6 +8,8 @@ "laravel-package", "data", "data-transfer-objects", + "data-mapper", + "object-mapper", "requests", "http" ], diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 4c6a9d6..9cfd127 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -55,16 +55,6 @@ function ($dataClass, $parameters, $app) { $this->app->alias(PropertyInfoExtractor::class, 'propertyInfo'); } - /** - * Register any application services. - * - * @return void - */ - public function register() - { - // - } - /** * Register new dynamic mappers. */ @@ -83,16 +73,5 @@ public static function registerMapper(string|array $mapper, bool $replacing = fa public static function getMappers(): array { return static::$mappers; - // $mappers = []; - - // foreach (static::$mappers as $mapper) { - // $mapperInstance = new $mapper; - - // if ($mapperInstance instanceof Mappers\DataMapper) { - // $mappers[] = $mapperInstance; - // } - // } - - // return $mappers; } } From d686e254546ccbec63db04a55ad087084f7a53ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 5 Jun 2025 09:27:39 +0200 Subject: [PATCH 11/13] code style fixes --- src/Attributes/ResolveModel.php | 2 +- src/Mapper.php | 14 ++- src/Mappers/CollectionDataMapper.php | 7 +- src/Mappers/DataMapper.php | 8 +- src/Mappers/ModelDataMapper.php | 22 ++--- src/Mappers/ObjectDataMapper.php | 17 ++-- src/MappingValue.php | 4 +- src/PropertyInfoExtractor.php | 22 ++--- src/ServiceProvider.php | 4 +- src/Support/TypeScript.php | 92 ++++++++++---------- src/Support/ValidationRules.php | 58 ++++++------ tests/Unit/MapperTest.php | 80 ++++++++--------- tests/Unit/UnitTestCase.php | 2 +- workbench/app/Models/User.php | 2 +- workbench/database/factories/UserFactory.php | 2 +- 15 files changed, 166 insertions(+), 170 deletions(-) diff --git a/src/Attributes/ResolveModel.php b/src/Attributes/ResolveModel.php index d067ed5..b8594fa 100644 --- a/src/Attributes/ResolveModel.php +++ b/src/Attributes/ResolveModel.php @@ -80,7 +80,7 @@ public function getMorphModel(string $fromPropertyKey, array $properties, array } $morphMap = Relation::morphMap(); - + $modelModelClass = array_filter( array_map( fn (string $morphType) => $morphMap[$morphType] ?? null, diff --git a/src/Mapper.php b/src/Mapper.php index 404b9fb..bd445af 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -7,8 +7,6 @@ use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDataMapper\MappingValue; -use OpenSoutheners\LaravelDataMapper\Enums\BuiltInType; use ReflectionClass; use ReflectionProperty; @@ -19,13 +17,13 @@ final class Mapper protected ?string $dataClass = null; protected ?string $throughClass = null; - + protected ?MappingValue $fromMappingValue = null; - + protected ?string $property = null; protected array $propertyTypes = []; - + protected bool $runningFromMapper = false; public function __construct(mixed $input) @@ -33,7 +31,7 @@ public function __construct(mixed $input) if (is_array($input) && count($input) === 1) { $input = reset($input); } - + if (is_object($input)) { $this->dataClass = get_class($input); } @@ -74,7 +72,7 @@ protected function takeDataFrom(mixed $input): mixed public function through(string $class): static { $this->throughClass = $class; - + return $this; } @@ -96,7 +94,7 @@ public function to(?string $output = null) $mappingDataValue = new MappingValue( data: $this->data, - allMappingData: (!$this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], + allMappingData: (! $this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], types: $this->propertyTypes, objectClass: $output, collectClass: $this->throughClass, diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php index 33a3cf4..84d6a21 100644 --- a/src/Mappers/CollectionDataMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -3,7 +3,6 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDataMapper\MappingValue; use Symfony\Component\TypeInfo\Type; @@ -32,7 +31,7 @@ public function resolve(MappingValue $mappingValue): void { if ($mappingValue->objectClass === EloquentCollection::class) { $mappingValue->data = $mappingValue->data->toBase(); - + return; } @@ -41,7 +40,7 @@ public function resolve(MappingValue $mappingValue): void is_string($mappingValue->data) => Collection::make(explode(',', $mappingValue->data)), default => Collection::make($mappingValue->data), }; - + if ($mappingValue->preferredTypeClass) { $collection = $collection->map(fn ($value) => map($value)->to($mappingValue->preferredTypeClass)); } @@ -49,7 +48,7 @@ public function resolve(MappingValue $mappingValue): void // if ($mappingValue->preferredType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { // $collection = $collection->all(); // } - + $mappingValue->data = $collection; } } diff --git a/src/Mappers/DataMapper.php b/src/Mappers/DataMapper.php index 0139581..abf4421 100644 --- a/src/Mappers/DataMapper.php +++ b/src/Mappers/DataMapper.php @@ -16,15 +16,15 @@ abstract public function assert(MappingValue $mappingValue): bool; * Resolve mapper that runs once assert returns true. */ abstract public function resolve(MappingValue $mappingValue): void; - + public function __invoke(MappingValue $mappingValue, Closure $next) { - if (!$this->assert($mappingValue)) { + if (! $this->assert($mappingValue)) { return $next($mappingValue); } - + $this->resolve($mappingValue); - + return $next($mappingValue); } } diff --git a/src/Mappers/ModelDataMapper.php b/src/Mappers/ModelDataMapper.php index 2d69682..7daa8d0 100644 --- a/src/Mappers/ModelDataMapper.php +++ b/src/Mappers/ModelDataMapper.php @@ -33,42 +33,42 @@ public function resolve(MappingValue $mappingValue): void if (is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data)) { /** @var Model $modelInstance */ $modelInstance = new $mappingValue->preferredTypeClass; - + foreach ($mappingValue->data as $key => $value) { if ($modelInstance->isRelation($key) && $modelInstance->$key() instanceof BelongsTo) { $modelInstance->$key()->associate($value); - + continue; } - + if ($modelInstance->isRelation($key) && $modelInstance->$key() instanceof HasMany) { $modelInstance->setRelation($key, map($value)->to(get_class($modelInstance->$key()->getModel()))); - + continue; } $modelInstance->fill([$key => $value]); } - + $mappingValue->data = $modelInstance; - + return; } - + if (is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) { $mappingValue->data = array_filter(explode(',', $mappingValue->data)); } - + if (count($mappingValue->types) <= 1) { $mappingValue->data = $this->resolveIntoModelInstance($mappingValue->data, $mappingValue->preferredTypeClass); } - + if ($mappingValue->collectClass === Collection::class) { $mappingValue->data = $mappingValue->data instanceof DatabaseCollection ? $mappingValue->data->toBase() : Collection::make($mappingValue->data); } - + // $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); // /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ @@ -128,7 +128,7 @@ public function resolve(MappingValue $mappingValue): void // if (! is_null($valueB)) { // $lastNonValue = $valueB; // } - + // return [$valueA, $valueB ?? $lastNonValue]; // }, // $data, diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 0470fdc..7839239 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -3,7 +3,6 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; use Illuminate\Contracts\Container\ContextualAttribute; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Str; use OpenSoutheners\LaravelDataMapper\Attributes\NormaliseProperties; @@ -44,7 +43,7 @@ public function assert(MappingValue $mappingValue): bool public function resolve(MappingValue $mappingValue): void { $class = new ReflectionClass($mappingValue->preferredTypeClass); - + $data = []; $mappingData = is_string($mappingValue->data) ? json_decode($mappingValue->data, true) : $mappingValue->data; @@ -59,7 +58,7 @@ public function resolve(MappingValue $mappingValue): void $value = $propertiesData[$key] ?? null; $type = app(PropertyInfoExtractor::class)->typeInfo($class->getName(), $key); - + /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ $propertyAttributes = Collection::make($property->getAttributes()); @@ -76,21 +75,21 @@ public function resolve(MappingValue $mappingValue): void if (is_null($value)) { continue; } - + $unwrappedType = app(PropertyInfoExtractor::class)->unwrapType($type); - + if ($type instanceof Type\NullableType) { $type = $type->getWrappedType(); } - + if ($type instanceof Type\CollectionType) { $data[$key] = map($value) ->through((string) $unwrappedType) ->to($type->getCollectionValueType()); - + continue; } - + $data[$key] = match (true) { $type instanceof Type\ObjectType => map($value)->to((string) $type), default => $value, @@ -106,7 +105,7 @@ public function resolve(MappingValue $mappingValue): void protected function normalisePropertyKey(MappingValue $mappingValue, string $key): ?string { $class = new ReflectionClass($mappingValue->objectClass); - + $normaliseProperty = count($class->getAttributes(NormaliseProperties::class)) > 0 ?: (app('config')->get('data-mapper.normalise_properties') ?? true); diff --git a/src/MappingValue.php b/src/MappingValue.php index 60f7ca7..dcec627 100644 --- a/src/MappingValue.php +++ b/src/MappingValue.php @@ -27,10 +27,10 @@ final class MappingValue public function __construct( public mixed $data, public readonly array $allMappingData, - + public readonly ?string $objectClass = null, public readonly ?string $collectClass = null, - + public readonly ?array $types = null, ) { $this->preferredType = $types ? (reset($types) ?? null) : null; diff --git a/src/PropertyInfoExtractor.php b/src/PropertyInfoExtractor.php index a603cf3..5e7c639 100644 --- a/src/PropertyInfoExtractor.php +++ b/src/PropertyInfoExtractor.php @@ -15,15 +15,15 @@ final class PropertyInfoExtractor public function __construct() { - $phpStanExtractor = new PhpStanExtractor(); - $reflectionExtractor = new ReflectionExtractor(); - + $phpStanExtractor = new PhpStanExtractor; + $reflectionExtractor = new ReflectionExtractor; + $this->extractor = new Extractor( [$reflectionExtractor], [$phpStanExtractor, $reflectionExtractor], ); } - + public function typeInfo(string $class, string $property, array $context = []): ?Type { return $this->extractor->getType($class, $property, $context); @@ -35,26 +35,26 @@ public function typeInfo(string $class, string $property, array $context = []): public function typeInfoFromClass(string $class, array $context = []): array { $classReflection = new ReflectionClass($class); - + $classProperties = $classReflection->getProperties(ReflectionProperty::IS_PUBLIC); - + $propertiesTypes = []; - + foreach ($classProperties as $property) { $propertiesTypes[$property->getName()] = $this->extractor->getType($class, $property->getName(), $context); } - + return $propertiesTypes; } - + public function unwrapType(Type $type): Type { $builtinType = $type; - + while (method_exists($builtinType, 'getWrappedType')) { $builtinType = $builtinType->getWrappedType(); } - + return $builtinType; } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 9cfd127..69bc4fc 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -50,8 +50,8 @@ function ($dataClass, $parameters, $app) { }); } ); - - $this->app->instance(PropertyInfoExtractor::class, new PropertyInfoExtractor()); + + $this->app->instance(PropertyInfoExtractor::class, new PropertyInfoExtractor); $this->app->alias(PropertyInfoExtractor::class, 'propertyInfo'); } diff --git a/src/Support/TypeScript.php b/src/Support/TypeScript.php index 8dde6d7..2f0239d 100644 --- a/src/Support/TypeScript.php +++ b/src/Support/TypeScript.php @@ -24,87 +24,87 @@ public function __construct( private array $script = [], private array $exportTypes = [] ) { - // + // } - + public function __toString(): string { $result = ''; - + foreach ($this->script as $exportName => $types) { $exportType = $this->exportTypes[$exportName] ?? 'type'; - + $result .= "export {$exportType} {$exportName} "; - + if ($exportType === 'type') { - $result .= "= "; + $result .= '= '; } - + $result .= is_string($types) ? $types : ("{\n".implode(",\n", $types).",\n};\n"); $result .= "\n"; } - + return $result; } - + public function mappingFrom(MappingValue $mappingValue): void { $mappingValue->data = $this->fromClass($mappingValue->data); } - + public function fromClass(string $class): self { if (is_a($class, Model::class, true)) { $this->fromModelObject($class); - + return $this; } - + if (is_enum($class)) { $this->fromEnum($class); - + return $this; } - + $properties = app(PropertyInfoExtractor::class)->typeInfoFromClass($class); - + $typeName = $this->typeName($class); $this->script[$typeName] = []; - + foreach ($properties as $propertyName => $type) { $this->script[$typeName][] = "{$propertyName}: {$this->fromType($type)}"; } - + return $this; } - + private function typeName(string $class): string { $reflectionClass = new ReflectionClass($class); - + $attributes = $reflectionClass->getAttributes(AsType::class); - + $asTypeAttribute = reset($attributes); - + if ($asTypeAttribute) { return $asTypeAttribute->newInstance()->typeName; } - + return class_basename($class); } - + private function fromModelObject(string $class) { $columns = Schema::getColumns((new $class)->getTable()); - + $typeName = $this->typeName($class); - + if (isset($this->script[$typeName])) { return $typeName; } - + $this->script[$typeName] = []; - + foreach ($columns as $column) { $type = match ($column['type_name']) { 'int2' => 'number', @@ -135,13 +135,13 @@ private function fromModelObject(string $class) 'uuid' => 'string', default => 'any', }; - + $type .= $column['nullable'] ? ' | null' : ''; - + $this->script[$typeName][] = "{$column['name']}: {$type}"; } } - + private function fromType(Type $type): string { return match (true) { @@ -153,59 +153,59 @@ private function fromType(Type $type): string default => 'any', }; } - + private function fromCollectionType(Type\CollectionType $type): string { $collectionKeyType = $this->fromType($type->getCollectionKeyType()); $collectionValueType = $type->getCollectionValueType(); $collectionValueType = $this->fromType($collectionValueType instanceof Type\CollectionType ? $collectionValueType->getWrappedType() : $collectionValueType); - + if ($collectionKeyType === 'int | string') { return "Array<{$collectionValueType}>"; } - + return "Record<{$collectionKeyType}, {$collectionValueType}>"; } - + private function fromUnionType(Type\UnionType $type): string { - $types = array_map(fn(Type $childrenType) => $this->fromType($childrenType), $type->getTypes()); - + $types = array_map(fn (Type $childrenType) => $this->fromType($childrenType), $type->getTypes()); + return implode(' | ', $types); } - + /** - * @param class-string $class + * @param class-string $class */ private function fromEnum(string $class): void { - if (!enum_is_backed($class)) { + if (! enum_is_backed($class)) { throw new \Exception('Non backed enums are not supported'); } $typeName = $this->typeName($class); - + $this->script[$typeName] = []; $this->exportTypes[$typeName] = 'enum'; - + foreach ($class::cases() as $case) { $this->script[$typeName][] = "{$case->name} = {$case->value}"; } } - + private function fromObjectType(Type\ObjectType $type): string { $class = $type->getClassName(); - + if (is_a($class, Collection::class, true)) { return 'Array'; } - + $this->fromClass($class); - + return array_key_last($this->script); } - + private function fromBuiltinType(Type\BuiltinType $type): string { return match ($type->getTypeIdentifier()) { diff --git a/src/Support/ValidationRules.php b/src/Support/ValidationRules.php index de0910e..da7e7c5 100644 --- a/src/Support/ValidationRules.php +++ b/src/Support/ValidationRules.php @@ -16,52 +16,52 @@ use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; -class ValidationRules implements MapeableObject, Arrayable, ArrayAccess +class ValidationRules implements Arrayable, ArrayAccess, MapeableObject { private array $rules = []; - + private ?ReflectionClass $class = null; - + public function fromClass(string $class): self { $this->class = new ReflectionClass($class); - + $properties = app(PropertyInfoExtractor::class)->typeInfoFromClass($class); - + foreach ($properties as $name => $type) { $this->rules[$name] = $this->getRulesForProperty($name, $type); } - + return $this; } - + public function mappingFrom(MappingValue $mappingValue): void { $mappingValue->data = $this->fromClass($mappingValue->data); } - + public function toArray(): array { return $this->rules; } - + public function getRulesForProperty(string $name, Type $type): array { $rules = $this->fromType($type); - + $reflectionProperty = $this->class->getProperty($name); - + $attributes = $reflectionProperty->getAttributes(); - - $containerAttributes = array_filter($attributes, fn(ReflectionAttribute $attribute) => in_array($attribute->getName(), [Authenticated::class, Inject::class])); - + + $containerAttributes = array_filter($attributes, fn (ReflectionAttribute $attribute) => in_array($attribute->getName(), [Authenticated::class, Inject::class])); + if ($reflectionProperty->hasDefaultValue() || count($containerAttributes) > 0) { $rules[] = 'nullable'; } - + return array_unique($rules); } - + private function fromType(Type $type): array { return match (true) { @@ -73,40 +73,40 @@ private function fromType(Type $type): array default => [], }; } - + private function fromEnumType(Type\EnumType $type): array { return [Rule::enum($type->getClassName())]; } - + private function fromObjectType(Type\ObjectType $type): array { $typeClass = $type->getClassName(); - + return match (true) { is_a($typeClass, Model::class, true) => ['string', 'numeric'], default => [], }; } - + private function fromCollectionType(Type\CollectionType $type): array { $valueType = $type->getCollectionValueType(); - + if ($valueType instanceof Type\CollectionType) { return $this->fromType($valueType->getWrappedType()); } - + return []; } - + private function fromUnionType(Type\UnionType $type): array { $rules = ['nullable']; - + return array_merge($rules, $this->fromType($type->getTypes()[0])); } - + private function fromBuiltinType(Type\BuiltinType $type): array { return match ($type->getTypeIdentifier()) { @@ -117,22 +117,22 @@ private function fromBuiltinType(Type\BuiltinType $type): array default => [], }; } - + public function offsetExists($offset): bool { return isset($this->rules[$offset]); } - + public function offsetGet($offset): mixed { return $this->rules[$offset]; } - + public function offsetSet($offset, $value): void { $this->rules[$offset] = $value; } - + public function offsetUnset($offset): void { unset($this->rules[$offset]); diff --git a/tests/Unit/MapperTest.php b/tests/Unit/MapperTest.php index b1eead5..39e9405 100644 --- a/tests/Unit/MapperTest.php +++ b/tests/Unit/MapperTest.php @@ -17,112 +17,112 @@ class MapperTest extends TestCase { - public function testMapNumericIdToModelResultsInModelInstance() + public function test_map_numeric_id_to_model_results_in_model_instance() { $user = UserFactory::new()->create(); - + $result = map('1')->to(User::class); - + $this->assertInstanceOf(User::class, $result); $this->assertEquals($user->email, $result->email); } - - public function testMapMultipleNumericIdsToModelResultsInCollectionOfModelInstances() + + public function test_map_multiple_numeric_ids_to_model_results_in_collection_of_model_instances() { $users = UserFactory::new()->count(2)->create(); - + $result = map('1, 2')->to(User::class); - + $this->assertInstanceOf(DatabaseCollection::class, $result); $this->assertEquals($users->first()->email, $result->first()->email); $this->assertEquals($users->last()->email, $result->last()->email); } - - public function testMapMultipleNumericIdsAsArgsToModelResultsInCollectionOfModelInstances() + + public function test_map_multiple_numeric_ids_as_args_to_model_results_in_collection_of_model_instances() { $users = UserFactory::new()->count(2)->create(); - + $result = map(1, 2)->to(User::class); - + $this->assertInstanceOf(DatabaseCollection::class, $result); $this->assertEquals($users->first()->email, $result->first()->email); $this->assertEquals($users->last()->email, $result->last()->email); } - - public function testMapMultipleNumericIdsToModelThroughBaseCollectionResultsInBaseCollectionOfModelInstances() + + public function test_map_multiple_numeric_ids_to_model_through_base_collection_results_in_base_collection_of_model_instances() { $users = UserFactory::new()->count(2)->create(); - + $result = map('1, 2')->through(Collection::class)->to(User::class); - + $this->assertTrue(get_class($result) === Collection::class); $this->assertEquals($users->first()->email, $result->first()->email); $this->assertEquals($users->last()->email, $result->last()->email); } - - public function testMapNumericTimestampToCarbonResultsInCarbonInstance() + + public function test_map_numeric_timestamp_to_carbon_results_in_carbon_instance() { $timestamp = 1747939147; $result = map($timestamp)->to(Carbon::class); - + $this->assertTrue(get_class($result) === \Illuminate\Support\Carbon::class); $this->assertEquals($timestamp, $result->timestamp); } - - public function testMapMultipleNumericTimestampsToCarbonResultsInCollectionOfCarbonInstances() + + public function test_map_multiple_numeric_timestamps_to_carbon_results_in_collection_of_carbon_instances() { $timestamps = [1747939147, 1757939147]; - + $result = map($timestamps)->through(Collection::class)->to(Carbon::class); - + $this->assertTrue(get_class($result) === Collection::class); $this->assertEquals(head($timestamps), $result->first()->timestamp); $this->assertEquals(last($timestamps), $result->last()->timestamp); } - - public function testMapMultipleNumericCsvTimestampsToCarbonResultsInCollectionOfCarbonInstances() + + public function test_map_multiple_numeric_csv_timestamps_to_carbon_results_in_collection_of_carbon_instances() { $timestamps = [1747939147, 1757939147]; - + $result = map(implode(',', $timestamps))->through(Collection::class)->to(Carbon::class); - + $this->assertTrue(get_class($result) === Collection::class); $this->assertEquals(head($timestamps), $result->first()->timestamp); $this->assertEquals(last($timestamps), $result->last()->timestamp); } - - public function testMapArrayToGenericObjectResultsInStdClassInstance() + + public function test_map_array_to_generic_object_results_in_std_class_instance() { $input = ['hello' => 'world', 'foo' => 'bar']; - + $result = map($input)->to(stdClass::class); - + $this->assertTrue(get_class($result) === stdClass::class); $this->assertEquals($input['hello'], $result->hello); $this->assertEquals($input['foo'], $result->foo); } - - public function testMapArraysAsArgsToGenericObjectResultsInCollectionOfStdClassInstances() + + public function test_map_arrays_as_args_to_generic_object_results_in_collection_of_std_class_instances() { $firstObject = ['hello' => 'world', 'foo' => 'bar']; $secondObject = ['one' => 'first', 'two' => 'second']; - + $result = map($firstObject, $secondObject)->through(Collection::class)->to(stdClass::class); - + $this->assertTrue(get_class($result) === Collection::class); $this->assertEquals($firstObject['hello'], $result[0]->hello); $this->assertEquals($secondObject['one'], $result[1]->one); } - - public function testMapStringToBackedEnumResultInBackedEnumInstance() + + public function test_map_string_to_backed_enum_result_in_backed_enum_instance() { $result = map('hidden')->to(PostStatus::class); $this->assertTrue(get_class($result) === PostStatus::class); $this->assertTrue($result === PostStatus::Hidden); } - - public function testMapStringsAsArgsToBackedEnumInCollectionOfBackedEnumInstances() + + public function test_map_strings_as_args_to_backed_enum_in_collection_of_backed_enum_instances() { $result = map('hidden', 'published')->through(Collection::class)->to(PostStatus::class); @@ -130,8 +130,8 @@ public function testMapStringsAsArgsToBackedEnumInCollectionOfBackedEnumInstance $this->assertTrue($result[0] === PostStatus::Hidden); $this->assertTrue($result[1] === PostStatus::Published); } - - public function testMapObjectToTypeScriptResultsInStringifiedScriptCode() + + public function test_map_object_to_type_script_results_in_stringified_script_code() { $result = (string) map(UpdatePostWithDefaultData::class)->to(TypeScript::class); diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php index 2665ae4..a0514a1 100644 --- a/tests/Unit/UnitTestCase.php +++ b/tests/Unit/UnitTestCase.php @@ -32,7 +32,7 @@ protected function setUp(): void Container::getInstance()->bind('config', fn () => $mockedConfig); } - + public function actAsUser($user = null) { $mockedAuth = Mockery::mock(AuthManager::class); diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php index c2cfe7e..3f4f08d 100644 --- a/workbench/app/Models/User.php +++ b/workbench/app/Models/User.php @@ -8,7 +8,7 @@ class User extends Authenticatable { use HasFactory; - + /** * The attributes that aren't mass assignable. * diff --git a/workbench/database/factories/UserFactory.php b/workbench/database/factories/UserFactory.php index 5bab9cb..f46686d 100644 --- a/workbench/database/factories/UserFactory.php +++ b/workbench/database/factories/UserFactory.php @@ -17,7 +17,7 @@ class UserFactory extends Factory * @var string */ protected $model = User::class; - + /** * Define the model's default state. * From d83308f09131b972dbcea4be8aa20a503214ac36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Thu, 5 Jun 2025 11:13:01 +0200 Subject: [PATCH 12/13] fix tests and segmentation fault (infinite loop on collection map) --- src/Mapper.php | 3 +++ src/Mappers/CollectionDataMapper.php | 25 +++++++++++-------- src/Mappers/ObjectDataMapper.php | 5 +++- tests/Unit/DataTransferObjectTest.php | 2 +- .../DataTransferObjects/CreatePostData.php | 6 +++-- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Mapper.php b/src/Mapper.php index bd445af..b828d2b 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -7,11 +7,14 @@ use Illuminate\Http\Request; use Illuminate\Pipeline\Pipeline; use Illuminate\Support\Collection; +use Illuminate\Support\Traits\Conditionable; use ReflectionClass; use ReflectionProperty; final class Mapper { + use Conditionable; + protected mixed $data; protected ?string $dataClass = null; diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php index 84d6a21..202c773 100644 --- a/src/Mappers/CollectionDataMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use OpenSoutheners\LaravelDataMapper\MappingValue; -use Symfony\Component\TypeInfo\Type; use function OpenSoutheners\ExtendedPhp\Strings\is_json_structure; use function OpenSoutheners\LaravelDataMapper\map; @@ -17,11 +16,11 @@ final class CollectionDataMapper extends DataMapper */ public function assert(MappingValue $mappingValue): bool { - return ($mappingValue->collectClass === Collection::class && is_array($mappingValue->data)) + return $mappingValue->collectClass === 'array' + || ($mappingValue->collectClass === Collection::class && is_array($mappingValue->data)) || ($mappingValue->collectClass === Collection::class && is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) - || $mappingValue->preferredType instanceof Type\CollectionType - || $mappingValue->preferredTypeClass === Collection::class - || $mappingValue->preferredTypeClass === EloquentCollection::class; + || $mappingValue->objectClass === Collection::class + || $mappingValue->objectClass === EloquentCollection::class; } /** @@ -40,14 +39,18 @@ public function resolve(MappingValue $mappingValue): void is_string($mappingValue->data) => Collection::make(explode(',', $mappingValue->data)), default => Collection::make($mappingValue->data), }; - - if ($mappingValue->preferredTypeClass) { - $collection = $collection->map(fn ($value) => map($value)->to($mappingValue->preferredTypeClass)); + + $collection = $collection->filter(); + + if ($mappingValue->collectClass === 'array') { + $mappingValue->data = $collection->all(); + + return; } - // if ($mappingValue->preferredType->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY) { - // $collection = $collection->all(); - // } + if ($mappingValue->objectClass && $mappingValue->objectClass !== Collection::class) { + $collection = $collection->map(fn ($value) => map($value)->to($mappingValue->objectClass)); + } $mappingValue->data = $collection; } diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 7839239..8a85d23 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -6,6 +6,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use OpenSoutheners\LaravelDataMapper\Attributes\NormaliseProperties; +use OpenSoutheners\LaravelDataMapper\Mapper; use OpenSoutheners\LaravelDataMapper\MappingValue; use OpenSoutheners\LaravelDataMapper\PropertyInfoExtractor; use ReflectionAttribute; @@ -83,9 +84,11 @@ public function resolve(MappingValue $mappingValue): void } if ($type instanceof Type\CollectionType) { + $collectionValueType = $type->getCollectionValueType(); + $data[$key] = map($value) ->through((string) $unwrappedType) - ->to($type->getCollectionValueType()); + ->to((string) $collectionValueType); continue; } diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index f6e4664..7731e11 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -14,7 +14,7 @@ class DataTransferObjectTest extends TestCase { - public function test_data_transfer_object_from_array() + public function test_object_as_data_transfer_object_from_array() { $data = map([ 'title' => 'Hello world', diff --git a/workbench/app/DataTransferObjects/CreatePostData.php b/workbench/app/DataTransferObjects/CreatePostData.php index 4f290e2..570b3c3 100644 --- a/workbench/app/DataTransferObjects/CreatePostData.php +++ b/workbench/app/DataTransferObjects/CreatePostData.php @@ -20,7 +20,7 @@ class CreatePostData implements RouteTransferableObject */ public function __construct( public string $title, - public ?array $tags, + public ?array $tags = null, public PostStatus $postStatus, public ?Post $post = null, public array|string|null $country = null, @@ -33,7 +33,9 @@ public function __construct( public ?Collection $dates = null, $authorEmail = null ) { - $this->tags ??= ['generic', 'post']; + if (count($this->tags) === 0) { + $this->tags = ['generic', 'post']; + } $this->authorEmail = $authorEmail; } From 5964c72ccddf9fb7afe4063f7aefb36aa93f9aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rube=CC=81n=20Robles?= Date: Fri, 6 Jun 2025 09:09:24 +0200 Subject: [PATCH 13/13] wip --- src/Mapper.php | 53 ++++--- src/Mappers/BackedEnumDataMapper.php | 24 ++- src/Mappers/CarbonDataMapper.php | 36 +++-- src/Mappers/CollectionDataMapper.php | 31 ++-- src/Mappers/DataMapper.php | 40 +++-- src/Mappers/GenericObjectDataMapper.php | 27 ++-- src/Mappers/MapeableObjectMapper.php | 12 +- src/Mappers/ModelDataMapper.php | 28 ++-- src/Mappers/ObjectDataMapper.php | 38 ++--- src/MappingValue.php | 36 +---- src/ServiceProvider.php | 12 +- .../DataTransferObjectTest.php | 137 +----------------- tests/{Unit => }/MapperTest.php | 3 +- tests/ObjectMappingTest.php | 38 +++++ tests/{Integration => }/TestCase.php | 4 +- tests/Unit/DataTransferObjectTest.php | 2 +- .../ValidatedDataTransferObjectTest.php | 2 +- workbench/app/DataObjects/CreateUserData.php | 11 ++ .../DataTransferObjects/UpdatePostData.php | 2 +- 19 files changed, 228 insertions(+), 308 deletions(-) rename tests/{Integration => }/DataTransferObjectTest.php (59%) rename tests/{Unit => }/MapperTest.php (97%) create mode 100644 tests/ObjectMappingTest.php rename tests/{Integration => }/TestCase.php (91%) rename tests/{Integration => }/ValidatedDataTransferObjectTest.php (98%) create mode 100644 workbench/app/DataObjects/CreateUserData.php diff --git a/src/Mapper.php b/src/Mapper.php index b828d2b..51f00d2 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -2,6 +2,8 @@ namespace OpenSoutheners\LaravelDataMapper; +use ArrayAccess; +use Countable; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Request; @@ -21,17 +23,9 @@ final class Mapper protected ?string $throughClass = null; - protected ?MappingValue $fromMappingValue = null; - - protected ?string $property = null; - - protected array $propertyTypes = []; - - protected bool $runningFromMapper = false; - public function __construct(mixed $input) { - if (is_array($input) && count($input) === 1) { + if ((is_array($input) || $input instanceof Countable) && count($input) === 1) { $input = reset($input); } @@ -62,7 +56,7 @@ protected function takeDataFrom(mixed $input): mixed is_object($input->route()) ? $input->route()->parameters() : [], $input instanceof FormRequest ? $input->validated() : $input->all() ), - $input instanceof Collection => $input->all(), + $input instanceof Collection => $input, $input instanceof Model => $input, is_object($input) => $this->extractProperties($input), default => $input, @@ -88,24 +82,35 @@ public function through(string $class): static public function to(?string $output = null) { $output ??= $this->dataClass; - - // if (is_array($this->data)) { - // $reflectionClass = $this->fromMappingValue?->class ?? $output ? new ReflectionClass($output) : null; - // } else { - // $reflectionClass = $output ? new ReflectionClass($output) : null; - // } - - $mappingDataValue = new MappingValue( + + if (!$this->throughClass && (is_array($this->data) || $this->data instanceof Collection)) { + $this->throughClass = is_array($this->data) ? 'array' : Collection::class; + } + + $mappingValue = new MappingValue( data: $this->data, - allMappingData: (! $this->runningFromMapper ? $this->fromMappingValue?->allMappingData : $this->data) ?? [], - types: $this->propertyTypes, objectClass: $output, collectClass: $this->throughClass, ); + + $mapper = Collection::make(ServiceProvider::getMappers()) + ->map(fn ($mapper) => ['mapper' => $mapper, 'score' => $mapper->score($mappingValue)]) + ->sortByDesc('score') + // ->dd() + ->first(); + + // dump($mappingValue); + // dump($mapper); + // if ($this->data instanceof Collection) { + // return; + // } + // + if (!$mapper || $mapper['score'] === 0) { + return $mappingValue->data; + } + + $mapper = $mapper['mapper']; - return app(Pipeline::class) - ->through(ServiceProvider::getMappers()) - ->send($mappingDataValue) - ->then(fn (MappingValue $mappingValue) => $mappingValue->data); + return $mapper($mappingValue); } } diff --git a/src/Mappers/BackedEnumDataMapper.php b/src/Mappers/BackedEnumDataMapper.php index 6af579d..eb79dd4 100644 --- a/src/Mappers/BackedEnumDataMapper.php +++ b/src/Mappers/BackedEnumDataMapper.php @@ -3,29 +3,23 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; use BackedEnum; +use Illuminate\Support\Collection; use OpenSoutheners\LaravelDataMapper\MappingValue; -use ReflectionEnum; final class BackedEnumDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return is_subclass_of($mappingValue->preferredTypeClass, BackedEnum::class) - && gettype($mappingValue->data) === (new ReflectionEnum($mappingValue->preferredTypeClass))->getBackingType()->getName(); + return [ + is_string($mappingValue->data) || is_int($mappingValue->data), + is_subclass_of($mappingValue->objectClass, BackedEnum::class), + ]; } - /** - * Resolve mapper that runs once assert returns true. - */ public function resolve(MappingValue $mappingValue): void { - $mappingValue->data = $mappingValue->preferredTypeClass::tryFrom($mappingValue->data) ?? ( - count($mappingValue->types) > 1 - ? $mappingValue->data - : null - ); + $mappingValue->data = $mappingValue->data instanceof Collection + ? $mappingValue->data->mapInto($mappingValue->objectClass) + : $mappingValue->objectClass::tryFrom($mappingValue->data); } } diff --git a/src/Mappers/CarbonDataMapper.php b/src/Mappers/CarbonDataMapper.php index 5fc52f1..7b6ff72 100644 --- a/src/Mappers/CarbonDataMapper.php +++ b/src/Mappers/CarbonDataMapper.php @@ -5,32 +5,38 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use OpenSoutheners\LaravelDataMapper\MappingValue; final class CarbonDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return in_array(gettype($mappingValue->data), ['string', 'integer'], true) - && ($mappingValue->preferredTypeClass === CarbonInterface::class - || is_subclass_of($mappingValue->preferredTypeClass, CarbonInterface::class)); + return [ + is_a($mappingValue->objectClass, CarbonInterface::class, true), + in_array(gettype($mappingValue->data), ['string', 'integer'], true), + is_iterable($mappingValue->data), + ]; } - /** - * Resolve mapper that runs once assert returns true. - */ public function resolve(MappingValue $mappingValue): void { - $mappingValue->data = match (true) { - gettype($mappingValue->data) === 'integer' || is_numeric($mappingValue->data) => Carbon::createFromTimestamp($mappingValue->data), - default => Carbon::make($mappingValue->data), + $mappingValue->data = is_array($mappingValue->data) || $mappingValue->data instanceof Collection + ? Collection::make($mappingValue->data)->map(fn ($item) => $this->resolveCarbon($item, $mappingValue->objectClass)) + : $this->resolveCarbon($mappingValue->data, $mappingValue->objectClass); + } + + private function resolveCarbon($value, string $objectClass): CarbonInterface + { + $carbonObject = match (true) { + gettype($value) === 'integer' || is_numeric($value) => Carbon::createFromTimestamp($value), + default => Carbon::make($value), }; - if ($mappingValue->preferredTypeClass === CarbonImmutable::class) { - $mappingValue->data = $mappingValue->data->toImmutable(); + if ($objectClass === CarbonImmutable::class) { + return $carbonObject->toImmutable(); } + + return $carbonObject; } } diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php index 202c773..e496ae6 100644 --- a/src/Mappers/CollectionDataMapper.php +++ b/src/Mappers/CollectionDataMapper.php @@ -11,16 +11,17 @@ final class CollectionDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return $mappingValue->collectClass === 'array' - || ($mappingValue->collectClass === Collection::class && is_array($mappingValue->data)) - || ($mappingValue->collectClass === Collection::class && is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) - || $mappingValue->objectClass === Collection::class - || $mappingValue->objectClass === EloquentCollection::class; + if (is_a($mappingValue->objectClass, Collection::class, true)) { + return [true]; + } + + return [ + !is_a($mappingValue->data, Collection::class), + $mappingValue->collectClass === 'array' || $mappingValue->collectClass === Collection::class, + $mappingValue->collectClass === Collection::class && is_array($mappingValue->data) || $mappingValue->collectClass === Collection::class && is_string($mappingValue->data) && str_contains($mappingValue->data, ','), + ]; } /** @@ -42,16 +43,12 @@ public function resolve(MappingValue $mappingValue): void $collection = $collection->filter(); - if ($mappingValue->collectClass === 'array') { - $mappingValue->data = $collection->all(); - - return; - } - if ($mappingValue->objectClass && $mappingValue->objectClass !== Collection::class) { - $collection = $collection->map(fn ($value) => map($value)->to($mappingValue->objectClass)); + $collection = map($collection)->to($mappingValue->objectClass); } - $mappingValue->data = $collection; + $mappingValue->data = $mappingValue->collectClass === 'array' + ? $collection->all() + : $collection; } } diff --git a/src/Mappers/DataMapper.php b/src/Mappers/DataMapper.php index abf4421..92510a9 100644 --- a/src/Mappers/DataMapper.php +++ b/src/Mappers/DataMapper.php @@ -3,28 +3,50 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; use Closure; +use Illuminate\Support\Facades\Log; use OpenSoutheners\LaravelDataMapper\MappingValue; abstract class DataMapper { /** - * Assert that this mapper resolves property with types given. + * Assertions count that this mapper resolves property with types given. + * + * @return array */ - abstract public function assert(MappingValue $mappingValue): bool; + abstract public function assert(MappingValue $mappingValue): array; /** * Resolve mapper that runs once assert returns true. */ abstract public function resolve(MappingValue $mappingValue): void; - - public function __invoke(MappingValue $mappingValue, Closure $next) + + public function score(MappingValue $mappingValue): float { - if (! $this->assert($mappingValue)) { - return $next($mappingValue); + $assertions = $this->assert($mappingValue); + + $total = count($assertions); + + $positive = count(array_filter($assertions)); + + if ($total === 0) { + return 0.0; } - + + return $positive / $total; + } + + public function __invoke(MappingValue $mappingValue) + { + if (config('app.debug')) { + Log::withContext([ + 'mappingData' => $mappingValue->data, + 'toClass' => $mappingValue->objectClass, + 'throughClass' => $mappingValue->collectClass, + ])->info('Mapping using class: '.static::class); + } + $this->resolve($mappingValue); - - return $next($mappingValue); + + return $mappingValue->data; } } diff --git a/src/Mappers/GenericObjectDataMapper.php b/src/Mappers/GenericObjectDataMapper.php index 4a6c004..783fa60 100644 --- a/src/Mappers/GenericObjectDataMapper.php +++ b/src/Mappers/GenericObjectDataMapper.php @@ -2,6 +2,8 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use OpenSoutheners\LaravelDataMapper\MappingValue; use stdClass; @@ -9,22 +11,23 @@ final class GenericObjectDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return $mappingValue->preferredTypeClass === stdClass::class - && (is_array($mappingValue->data) || is_json_structure($mappingValue->data)); + return [ + $mappingValue->objectClass === stdClass::class, + is_json_structure($mappingValue->data) || (is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data)) || (is_array($mappingValue->data[0] ?? null) && Arr::isAssoc($mappingValue->data[0])), + ]; } - /** - * Resolve mapper that runs once assert returns true. - */ public function resolve(MappingValue $mappingValue): void { - $mappingValue->data = is_array($mappingValue->data) - ? (object) $mappingValue->data - : json_decode($mappingValue->data); + $mappingValue->data = $mappingValue->data instanceof Collection + ? $mappingValue->data->map(fn($item) => $this->newObjectInstance($item)) + : $this->newObjectInstance($mappingValue->data); + } + + protected function newObjectInstance(mixed $data): stdClass + { + return is_array($data) ? (object) $data : json_decode($data); } } diff --git a/src/Mappers/MapeableObjectMapper.php b/src/Mappers/MapeableObjectMapper.php index 718e00b..f58da12 100644 --- a/src/Mappers/MapeableObjectMapper.php +++ b/src/Mappers/MapeableObjectMapper.php @@ -7,17 +7,13 @@ class MapeableObjectMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return is_a($mappingValue->objectClass, MapeableObject::class, true); + return [ + is_a($mappingValue->objectClass, MapeableObject::class, true), + ]; } - /** - * Resolve mapper that runs once assert returns true. - */ public function resolve(MappingValue $mappingValue): void { app($mappingValue->objectClass)->mappingFrom($mappingValue); diff --git a/src/Mappers/ModelDataMapper.php b/src/Mappers/ModelDataMapper.php index 7daa8d0..3722221 100644 --- a/src/Mappers/ModelDataMapper.php +++ b/src/Mappers/ModelDataMapper.php @@ -16,13 +16,12 @@ final class ModelDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - return $mappingValue->preferredTypeClass === Model::class - || is_subclass_of($mappingValue->preferredTypeClass, Model::class); + return [ + is_array($mappingValue->originalData) || is_string($mappingValue->originalData) || is_int($mappingValue->originalData), + is_a($mappingValue->objectClass, Model::class, true), + ]; } /** @@ -32,7 +31,7 @@ public function resolve(MappingValue $mappingValue): void { if (is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data)) { /** @var Model $modelInstance */ - $modelInstance = new $mappingValue->preferredTypeClass; + $modelInstance = new $mappingValue->objectClass; foreach ($mappingValue->data as $key => $value) { if ($modelInstance->isRelation($key) && $modelInstance->$key() instanceof BelongsTo) { @@ -58,16 +57,23 @@ public function resolve(MappingValue $mappingValue): void if (is_string($mappingValue->data) && str_contains($mappingValue->data, ',')) { $mappingValue->data = array_filter(explode(',', $mappingValue->data)); } - - if (count($mappingValue->types) <= 1) { - $mappingValue->data = $this->resolveIntoModelInstance($mappingValue->data, $mappingValue->preferredTypeClass); - } + + $mappingValue->data = $this->resolveIntoModelInstance($mappingValue->data, $mappingValue->objectClass); if ($mappingValue->collectClass === Collection::class) { $mappingValue->data = $mappingValue->data instanceof DatabaseCollection ? $mappingValue->data->toBase() : Collection::make($mappingValue->data); } + + if ($mappingValue->collectClass === 'array') { + $mappingValue->data = $mappingValue->data->all(); + } + + // TODO: Move to ObjectDataMapper + // if (count($mappingValue->types) <= 1) { + // $mappingValue->data = $this->resolveIntoModelInstance($mappingValue->data, $mappingValue->objectClass); + // } // $resolveModelAttributeReflector = $mappingValue->property->getAttributes(ResolveModel::class); diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php index 8a85d23..8e1384f 100644 --- a/src/Mappers/ObjectDataMapper.php +++ b/src/Mappers/ObjectDataMapper.php @@ -3,10 +3,11 @@ namespace OpenSoutheners\LaravelDataMapper\Mappers; use Illuminate\Contracts\Container\ContextualAttribute; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use OpenSoutheners\LaravelDataMapper\Attributes\NormaliseProperties; -use OpenSoutheners\LaravelDataMapper\Mapper; use OpenSoutheners\LaravelDataMapper\MappingValue; use OpenSoutheners\LaravelDataMapper\PropertyInfoExtractor; use ReflectionAttribute; @@ -20,30 +21,23 @@ final class ObjectDataMapper extends DataMapper { - /** - * Assert that this mapper resolves property with types given. - */ - public function assert(MappingValue $mappingValue): bool + public function assert(MappingValue $mappingValue): array { - if ( - ! $mappingValue->preferredTypeClass - || $mappingValue->preferredTypeClass === stdClass::class - || ! class_exists($mappingValue->preferredTypeClass) - || ! (new ReflectionClass($mappingValue->preferredTypeClass))->isInstantiable() - ) { - return false; + if (is_a($mappingValue->objectClass, Collection::class, true) || is_a($mappingValue->objectClass, Model::class, true)) { + return [false]; } - - return is_array($mappingValue->data) - && (is_string(array_key_first($mappingValue->data)) || is_json_structure($mappingValue->data)); + + return [ + $mappingValue->objectClass, + $mappingValue->objectClass !== stdClass::class && class_exists($mappingValue->objectClass) && (new ReflectionClass($mappingValue->objectClass))->isInstantiable(), + is_string($mappingValue->data) && is_json_structure($mappingValue->data), + is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data), + ]; } - /** - * Resolve mapper that runs once assert returns true. - */ public function resolve(MappingValue $mappingValue): void { - $class = new ReflectionClass($mappingValue->preferredTypeClass); + $class = new ReflectionClass($mappingValue->objectClass); $data = []; @@ -99,7 +93,7 @@ public function resolve(MappingValue $mappingValue): void }; } - $mappingValue->data = new $mappingValue->preferredTypeClass(...$data); + $mappingValue->data = new $mappingValue->objectClass(...$data); } /** @@ -123,8 +117,8 @@ protected function normalisePropertyKey(MappingValue $mappingValue, string $key) $camelKey = Str::camel($key); return match (true) { - property_exists($mappingValue->preferredTypeClass, $key) => $key, - property_exists($mappingValue->preferredTypeClass, $camelKey) => $camelKey, + property_exists($mappingValue->objectClass, $key) => $key, + property_exists($mappingValue->objectClass, $camelKey) => $camelKey, default => null }; } diff --git a/src/MappingValue.php b/src/MappingValue.php index dcec627..70df666 100644 --- a/src/MappingValue.php +++ b/src/MappingValue.php @@ -2,45 +2,19 @@ namespace OpenSoutheners\LaravelDataMapper; -use Illuminate\Support\Collection; -use Symfony\Component\TypeInfo\Type; - final class MappingValue { - public readonly ?Type $preferredType; - - /** - * @var class-string|null - */ - public readonly ?string $preferredTypeClass; - - /** - * @var Collection<\ReflectionAttribute> - */ - public readonly Collection $attributes; - + public readonly mixed $originalData; + /** - * @param class-string|null $objectClass - * @param class-string|null $collectClass - * @param array|null $types + * @param class-string|string|null $objectClass + * @param class-string|string|null $collectClass */ public function __construct( public mixed $data, - public readonly array $allMappingData, - public readonly ?string $objectClass = null, public readonly ?string $collectClass = null, - - public readonly ?array $types = null, ) { - $this->preferredType = $types ? (reset($types) ?? null) : null; - - $this->preferredTypeClass = $this->preferredType ? ($this->preferredType->getClassName() ?: $objectClass) : $objectClass; - - // if ($property) { - // $this->attributes = Collection::make($property->getAttributes()); - // } else { - // $this->attributes = Collection::make($class ? $class->getAttributes() : []); - // } + $this->originalData = $data; } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 69bc4fc..cbe6c15 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -6,6 +6,7 @@ use Illuminate\Support\ServiceProvider as BaseServiceProvider; use OpenSoutheners\LaravelDataMapper\Attributes\Validate; use OpenSoutheners\LaravelDataMapper\Contracts\RouteTransferableObject; +use OpenSoutheners\LaravelDataMapper\Mappers; use ReflectionClass; class ServiceProvider extends BaseServiceProvider @@ -14,7 +15,6 @@ class ServiceProvider extends BaseServiceProvider Mappers\MapeableObjectMapper::class, Mappers\CollectionDataMapper::class, Mappers\ModelDataMapper::class, - Mappers\CarbonDataMapper::class, Mappers\BackedEnumDataMapper::class, Mappers\GenericObjectDataMapper::class, @@ -68,10 +68,16 @@ public static function registerMapper(string|array $mapper, bool $replacing = fa /** * Get dynamic mappers. * - * @return array> + * @return array */ public static function getMappers(): array { - return static::$mappers; + $mapperInstances = []; + + foreach (static::$mappers as $mapper) { + $mapperInstances[] = app()->make($mapper); + } + + return $mapperInstances; } } diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/DataTransferObjectTest.php similarity index 59% rename from tests/Integration/DataTransferObjectTest.php rename to tests/DataTransferObjectTest.php index 4c98856..e7b06c2 100644 --- a/tests/Integration/DataTransferObjectTest.php +++ b/tests/DataTransferObjectTest.php @@ -1,6 +1,6 @@ 2, + 'post' => 2, 'parent' => 1, 'tags' => 'test,hello', ])->to(UpdatePostData::class); - $this->assertTrue($data->post_id?->is($post)); + $this->assertTrue($data->post?->is($post)); $this->assertTrue($data->parent?->is($parentPost)); } - public function test_data_transfer_object_with_default_value_attribute() - { - $this->markTestSkipped('To implement default values'); - - $user = User::create([ - 'email' => 'ruben@hello.com', - 'password' => '1234', - 'name' => 'Ruben', - ]); - - $this->actingAs($user); - - $fooBarPost = PostFactory::new()->create([ - 'title' => 'Foo bar', - 'slug' => 'foo-bar', - ]); - - $helloWorldPost = PostFactory::new()->create([ - 'title' => 'Hello world', - 'slug' => 'hello-world', - ]); - - Route::post('/posts/{post?}', function (UpdatePostWithDefaultData $data) { - return response()->json((array) $data); - }); - - $response = $this->postJson('/posts', []); - - $response->assertJsonFragment([ - 'author' => $user->toArray(), - 'post' => $helloWorldPost->toArray(), - ]); - } - - public function test_data_transfer_object_with_default_value_attribute_gets_bound_when_one_is_sent() - { - $user = User::create([ - 'email' => 'ruben@hello.com', - 'password' => '1234', - 'name' => 'Ruben', - ]); - - $this->actingAs($user); - - $fooBarPost = PostFactory::new()->create([ - 'title' => 'Foo bar', - 'slug' => 'foo-bar', - ]); - - $helloWorldPost = PostFactory::new()->create([ - 'title' => 'Hello world', - 'slug' => 'hello-world', - ]); - - Route::post('/posts/{post}', function (UpdatePostWithDefaultData $data) { - return response()->json((array) $data); - }); - - $response = $this->postJson('/posts/foo-bar', []); - - $response->assertJsonFragment([ - 'author' => $user->toArray(), - 'post' => $fooBarPost->toArray(), - ]); - } - - public function test_data_transfer_object_with_morphs_gets_models_bound_of_each_type_sent() - { - $user = User::create([ - 'email' => 'ruben@hello.com', - 'password' => '1234', - 'name' => 'Ruben', - ]); - - $this->actingAs($user); - - $horrorTag = TagFactory::new()->create([ - 'name' => 'Horror', - 'slug' => 'horror', - ]); - - $fooBarPost = PostFactory::new()->create([ - 'title' => 'Foo bar', - 'slug' => 'foo-bar', - ]); - - $helloWorldPost = PostFactory::new()->create([ - 'title' => 'Hello world', - 'slug' => 'hello-world', - ]); - - $myFilm = FilmFactory::new()->create([ - 'title' => 'My Film', - 'slug' => 'my-film', - 'year' => 1997, - ]); - - $response = $this->patchJson('tags/1', [ - 'name' => 'Scary', - 'taggable' => Collection::make([$myFilm->getKey(), $fooBarPost->getKey(), $helloWorldPost->getKey()])->join(', '), - // TODO: Fix mapping by slug - // 'taggable' => '1, foo-bar, hello-world', - 'taggable_type' => 'film, post', - ]); - - $response->assertSuccessful(); - - $response->assertJsonCount(3, 'data.taggable'); - - $response->assertJsonFragment([ - 'id' => 1, - 'title' => 'My Film', - 'year' => '1997', - 'about' => null, - ]); - - $response->assertJsonFragment([ - 'id' => 1, - 'title' => 'Foo bar', - 'slug' => 'foo-bar', - 'status' => 'published', - ]); - - $response->assertJsonFragment([ - 'id' => 2, - 'title' => 'Hello world', - 'slug' => 'hello-world', - 'status' => 'published', - ]); - } - public function test_nested_data_transfer_objects_gets_the_nested_as_object_instance() { $this->markTestIncomplete('Need to create nested actions/DTOs'); diff --git a/tests/Unit/MapperTest.php b/tests/MapperTest.php similarity index 97% rename from tests/Unit/MapperTest.php rename to tests/MapperTest.php index 39e9405..099a23d 100644 --- a/tests/Unit/MapperTest.php +++ b/tests/MapperTest.php @@ -1,12 +1,11 @@ 'John Doe', + 'email' => 'john@example.com', + ])->to(CreateUserData::class); + + $this->assertIsString($data->name); + $this->assertIsString($data->email); + + $this->assertEquals('John Doe', $data->name); + $this->assertEquals('john@example.com', $data->email); + } + + public function testMappingToObjectFromJsonString() + { + $data = map(json_encode([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]))->to(CreateUserData::class); + + $this->assertIsString($data->name); + $this->assertIsString($data->email); + + $this->assertEquals('John Doe', $data->name); + $this->assertEquals('john@example.com', $data->email); + } +} diff --git a/tests/Integration/TestCase.php b/tests/TestCase.php similarity index 91% rename from tests/Integration/TestCase.php rename to tests/TestCase.php index 4affd65..d6361f4 100644 --- a/tests/Integration/TestCase.php +++ b/tests/TestCase.php @@ -1,6 +1,6 @@ '', ]); - $app['config']->set('data-mapper', include_once __DIR__.'/../../config/data-mapper.php'); + $app['config']->set('data-mapper', include_once __DIR__.'/../config/data-mapper.php'); } } diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index 7731e11..91dc4a5 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -4,7 +4,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDataMapper\Tests\Integration\TestCase; +use OpenSoutheners\LaravelDataMapper\Tests\TestCase; use Workbench\App\DataTransferObjects\CreateComment; use Workbench\App\DataTransferObjects\CreateManyPostData; use Workbench\App\DataTransferObjects\CreatePostData; diff --git a/tests/Integration/ValidatedDataTransferObjectTest.php b/tests/ValidatedDataTransferObjectTest.php similarity index 98% rename from tests/Integration/ValidatedDataTransferObjectTest.php rename to tests/ValidatedDataTransferObjectTest.php index 24eace3..925141f 100644 --- a/tests/Integration/ValidatedDataTransferObjectTest.php +++ b/tests/ValidatedDataTransferObjectTest.php @@ -1,6 +1,6 @@