diff --git a/CHANGELOG.md b/CHANGELOG.md index fd43a70..8f4e853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 ### Added 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 d6f6281..04a8ef2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "open-southeners/laravel-dto", - "description": "Integrate data transfer objects into Laravel, the easiest way", + "name": "open-southeners/laravel-data-mapper", + "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" ], @@ -30,10 +32,11 @@ "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", + "laravel/pint": "^1.22", "orchestra/testbench": "^9.0 || ^10.0", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^11.0" @@ -42,12 +45,15 @@ "prefer-stable": true, "autoload": { "psr-4": { - "OpenSoutheners\\LaravelDto\\": "src" - } + "OpenSoutheners\\LaravelDataMapper\\": "src" + }, + "files": [ + "src/functions.php" + ] }, "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/" @@ -59,7 +65,7 @@ "extra": { "laravel": { "providers": [ - "OpenSoutheners\\LaravelDto\\ServiceProvider" + "OpenSoutheners\\LaravelDataMapper\\ServiceProvider" ] } }, diff --git a/config/data-transfer-objects.php b/config/data-mapper.php similarity index 93% rename from config/data-transfer-objects.php rename to config/data-mapper.php index 7249d97..a396b2c 100644 --- a/config/data-transfer-objects.php +++ b/config/data-mapper.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..c6ca2e9 100644 --- a/src/Attributes/AsType.php +++ b/src/Attributes/AsType.php @@ -1,6 +1,6 @@ make($attribute->value); + } +} diff --git a/src/Attributes/ModelWith.php b/src/Attributes/ModelWith.php new file mode 100644 index 0000000..bbad9f4 --- /dev/null +++ b/src/Attributes/ModelWith.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 */ @@ -55,28 +54,19 @@ 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); } - 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); + return self::getDefaultMorphKeyFrom($fromPropertyKey); } public function getMorphModel(string $fromPropertyKey, array $properties, array $propertyTypeClasses = []): array @@ -90,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, @@ -98,12 +89,9 @@ 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) + fn (string $class) => in_array((new $class)->getMorphClass(), $types) ); $modelModelClass = reset($modelModelClass); diff --git a/src/Attributes/Validate.php b/src/Attributes/Validate.php new file mode 100644 index 0000000..3117479 --- /dev/null +++ b/src/Attributes/Validate.php @@ -0,0 +1,14 @@ +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 @@ -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. - */ - public function filled(string $property): bool - { - /** @var \Illuminate\Http\Request $request */ - $request = app(Request::class); - $camelProperty = Str::camel($property); - - if (app()->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; - } - - $propertyInfoExtractor = PropertiesMapper::propertyInfoExtractor(); - - $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 = $propertyInfoExtractor->getTypes(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; - } - - /** - * 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. - * - * @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 PropertiesMapper(array_merge($data), static::class); - - $propertiesMapper->run(); - - $data = $propertiesMapper->get(); - - foreach ($properties as $property) { - $key = $property->getName(); - - $this->{$key} = $data[$key] ?? $property->getDefaultValue(); - } - } -} diff --git a/src/Mapper.php b/src/Mapper.php new file mode 100644 index 0000000..51f00d2 --- /dev/null +++ b/src/Mapper.php @@ -0,0 +1,116 @@ +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) { + $input instanceof Request => array_merge( + is_object($input->route()) ? $input->route()->parameters() : [], + $input instanceof FormRequest ? $input->validated() : $input->all() + ), + $input instanceof Collection => $input, + $input instanceof Model => $input, + is_object($input) => $this->extractProperties($input), + default => $input, + }; + } + + /** + * Map values through class. + */ + public function through(string $class): static + { + $this->throughClass = $class; + + return $this; + } + + /** + * @template T of object + * + * @param class-string $output + * @return T + */ + public function to(?string $output = null) + { + $output ??= $this->dataClass; + + 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, + 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 $mapper($mappingValue); + } +} diff --git a/src/Mappers/BackedEnumDataMapper.php b/src/Mappers/BackedEnumDataMapper.php new file mode 100644 index 0000000..eb79dd4 --- /dev/null +++ b/src/Mappers/BackedEnumDataMapper.php @@ -0,0 +1,25 @@ +data) || is_int($mappingValue->data), + is_subclass_of($mappingValue->objectClass, BackedEnum::class), + ]; + } + + public function resolve(MappingValue $mappingValue): void + { + $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 new file mode 100644 index 0000000..7b6ff72 --- /dev/null +++ b/src/Mappers/CarbonDataMapper.php @@ -0,0 +1,42 @@ +objectClass, CarbonInterface::class, true), + in_array(gettype($mappingValue->data), ['string', 'integer'], true), + is_iterable($mappingValue->data), + ]; + } + + public function resolve(MappingValue $mappingValue): void + { + $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 ($objectClass === CarbonImmutable::class) { + return $carbonObject->toImmutable(); + } + + return $carbonObject; + } +} diff --git a/src/Mappers/CollectionDataMapper.php b/src/Mappers/CollectionDataMapper.php new file mode 100644 index 0000000..e496ae6 --- /dev/null +++ b/src/Mappers/CollectionDataMapper.php @@ -0,0 +1,54 @@ +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, ','), + ]; + } + + /** + * Resolve mapper that runs once assert returns true. + */ + public function resolve(MappingValue $mappingValue): void + { + if ($mappingValue->objectClass === EloquentCollection::class) { + $mappingValue->data = $mappingValue->data->toBase(); + + return; + } + + $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), + }; + + $collection = $collection->filter(); + + if ($mappingValue->objectClass && $mappingValue->objectClass !== Collection::class) { + $collection = map($collection)->to($mappingValue->objectClass); + } + + $mappingValue->data = $mappingValue->collectClass === 'array' + ? $collection->all() + : $collection; + } +} diff --git a/src/Mappers/DataMapper.php b/src/Mappers/DataMapper.php new file mode 100644 index 0000000..92510a9 --- /dev/null +++ b/src/Mappers/DataMapper.php @@ -0,0 +1,52 @@ + + */ + abstract public function assert(MappingValue $mappingValue): array; + + /** + * Resolve mapper that runs once assert returns true. + */ + abstract public function resolve(MappingValue $mappingValue): void; + + public function score(MappingValue $mappingValue): float + { + $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 $mappingValue->data; + } +} diff --git a/src/Mappers/GenericObjectDataMapper.php b/src/Mappers/GenericObjectDataMapper.php new file mode 100644 index 0000000..783fa60 --- /dev/null +++ b/src/Mappers/GenericObjectDataMapper.php @@ -0,0 +1,33 @@ +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])), + ]; + } + + public function resolve(MappingValue $mappingValue): void + { + $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 new file mode 100644 index 0000000..f58da12 --- /dev/null +++ b/src/Mappers/MapeableObjectMapper.php @@ -0,0 +1,21 @@ +objectClass, MapeableObject::class, true), + ]; + } + + public function resolve(MappingValue $mappingValue): void + { + app($mappingValue->objectClass)->mappingFrom($mappingValue); + } +} diff --git a/src/Mappers/ModelDataMapper.php b/src/Mappers/ModelDataMapper.php new file mode 100644 index 0000000..3722221 --- /dev/null +++ b/src/Mappers/ModelDataMapper.php @@ -0,0 +1,199 @@ +originalData) || is_string($mappingValue->originalData) || is_int($mappingValue->originalData), + is_a($mappingValue->objectClass, Model::class, true), + ]; + } + + /** + * Resolve mapper that runs once assert returns true. + */ + public function resolve(MappingValue $mappingValue): void + { + if (is_array($mappingValue->data) && Arr::isAssoc($mappingValue->data)) { + /** @var Model $modelInstance */ + $modelInstance = new $mappingValue->objectClass; + + 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)); + } + + $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); + + // /** @var \ReflectionAttribute<\OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel>|null $resolveModelAttributeReflector */ + // $resolveModelAttributeReflector = reset($resolveModelAttributeReflector); + + // /** @var \OpenSoutheners\LaravelDataMapper\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)); + } + + /** + * 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 = null, array $withAttributes = [], ?ResolveModel $bindingAttribute = null): mixed + { + $usingAttribute = null; + $with = []; + + if ($bindingAttribute && $propertyKey) { + $with = $withAttributes[$modelClass] ?? []; + $usingAttribute = $bindingAttribute->getBindingAttribute($propertyKey, $modelClass, $with); + } + + return $this->getModelInstance($modelClass, $keys, $usingAttribute, $with); + } +} diff --git a/src/Mappers/ObjectDataMapper.php b/src/Mappers/ObjectDataMapper.php new file mode 100644 index 0000000..8e1384f --- /dev/null +++ b/src/Mappers/ObjectDataMapper.php @@ -0,0 +1,125 @@ +objectClass, Collection::class, true) || is_a($mappingValue->objectClass, Model::class, true)) { + return [false]; + } + + 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), + ]; + } + + public function resolve(MappingValue $mappingValue): void + { + $class = new ReflectionClass($mappingValue->objectClass); + + $data = []; + + $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 ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + $key = $property->getName(); + $value = $propertiesData[$key] ?? null; + + $type = app(PropertyInfoExtractor::class)->typeInfo($class->getName(), $key); + + /** @var \Illuminate\Support\Collection<\ReflectionAttribute> $propertyAttributes */ + $propertyAttributes = Collection::make($property->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; + } + + $unwrappedType = app(PropertyInfoExtractor::class)->unwrapType($type); + + if ($type instanceof Type\NullableType) { + $type = $type->getWrappedType(); + } + + if ($type instanceof Type\CollectionType) { + $collectionValueType = $type->getCollectionValueType(); + + $data[$key] = map($value) + ->through((string) $unwrappedType) + ->to((string) $collectionValueType); + + continue; + } + + $data[$key] = match (true) { + $type instanceof Type\ObjectType => map($value)->to((string) $type), + default => $value, + }; + } + + $mappingValue->data = new $mappingValue->objectClass(...$data); + } + + /** + * Normalise property key using camel case or original. + */ + 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); + + if (! $normaliseProperty) { + return $key; + } + + if (Str::endsWith($key, '_id')) { + $key = Str::replaceLast('_id', '', $key); + } + + $camelKey = Str::camel($key); + + return match (true) { + property_exists($mappingValue->objectClass, $key) => $key, + property_exists($mappingValue->objectClass, $camelKey) => $camelKey, + default => null + }; + } +} diff --git a/src/MappingValue.php b/src/MappingValue.php new file mode 100644 index 0000000..70df666 --- /dev/null +++ b/src/MappingValue.php @@ -0,0 +1,20 @@ +originalData = $data; + } +} 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/PropertyInfoExtractor.php b/src/PropertyInfoExtractor.php new file mode 100644 index 0000000..5e7c639 --- /dev/null +++ b/src/PropertyInfoExtractor.php @@ -0,0 +1,60 @@ +extractor = new Extractor( + [$reflectionExtractor], + [$phpStanExtractor, $reflectionExtractor], + ); + } + + public function typeInfo(string $class, string $property, array $context = []): ?Type + { + 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/ServiceProvider.php b/src/ServiceProvider.php index 973fa17..cbe6c15 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -1,15 +1,26 @@ 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->bind('dto.context.booted', fn () => ''); - $this->app->beforeResolving( - DataTransferObject::class, + RouteTransferableObject::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) - )); + $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); + }); } ); + + $this->app->instance(PropertyInfoExtractor::class, new PropertyInfoExtractor); + $this->app->alias(PropertyInfoExtractor::class, 'propertyInfo'); + } + + /** + * Register new dynamic mappers. + */ + public static function registerMapper(string|array $mapper, bool $replacing = false): void + { + $mappers = (array) $mapper; + + static::$mappers = $replacing ? $mappers : array_merge(static::$mappers, $mapper); } /** - * Register any application services. + * Get dynamic mappers. * - * @return void + * @return array */ - public function register() + public static function getMappers(): array { - // + $mapperInstances = []; + + foreach (static::$mappers as $mapper) { + $mapperInstances[] = app()->make($mapper); + } + + return $mapperInstances; } } diff --git a/src/Support/TypeScript.php b/src/Support/TypeScript.php new file mode 100644 index 0000000..2f0239d --- /dev/null +++ b/src/Support/TypeScript.php @@ -0,0 +1,226 @@ +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 + { + $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', + '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..da7e7c5 --- /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 0503a60..0000000 --- a/src/TypeGenerator.php +++ /dev/null @@ -1,233 +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; - } - - /** @var array<\ReflectionProperty> $properties */ - $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); - $propertyInfoExtractor = PropertiesMapper::propertyInfoExtractor(); - - $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()) ?? []; - $propertyType = reset($propertyTypes); - - $propertyTypeClass = $propertyType ? $propertyType->getClassName() : null; - - if (is_a($propertyTypeClass, Authenticatable::class, true)) { - continue; - } - - $propertyTypeAsString = $this->extractTypeFromPropertyType($propertyType); - $propertyKeyAsString = $property->getName(); - - if ($normalisesPropertiesKeys) { - $propertyKeyAsString = Str::snake($propertyKeyAsString); - $propertyKeyAsString .= is_subclass_of($propertyTypeClass, Model::class) ? '_id' : ''; - } - - $nullMark = $this->isNullableProperty( - $propertyType, - $property->getName(), - $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/src/functions.php b/src/functions.php new file mode 100644 index 0000000..addc3ae --- /dev/null +++ b/src/functions.php @@ -0,0 +1,8 @@ +forceFill([ + 'id' => 1, + 'name' => 'Ruben', + 'email' => 'ruben@hello.com', + 'password' => '', + ]); + + $this->partialMock('auth', function (MockInterface $mock) use ($user) { + $mock->expects('userResolver')->andReturn(fn () => $user); + }); + + /** @var CreatePostFormRequest */ + $mock = Mockery::mock(app(CreatePostFormRequest::class))->makePartial(); + + $mock->shouldReceive('route')->andReturn('example'); + $mock->shouldReceive('validated')->andReturn([ + 'title' => 'Hello world', + 'tags' => ['foo', 'bar', 'test'], + 'subscribers' => 'hello@world.com,hola@mundo.com,', + 'post_status' => PostStatus::Published->value, + ]); + + // Not absolutely the same but does the job... + app()->bind(Request::class, fn () => $mock); + + $data = map($mock)->to(CreatePostData::class); + + $this->assertTrue($data->postStatus instanceof PostStatus); + $this->assertEquals('Hello world', $data->title); + $this->assertIsArray($data->tags); + $this->assertContains('bar', $data->tags); + $this->assertTrue($data->subscribers instanceof Collection); + $this->assertContains('hello@world.com', $data->subscribers->all()); + $this->assertContains('hola@mundo.com', $data->subscribers->all()); + $this->assertContains('bar', $data->tags); + $this->assertTrue($user->is($data->currentUser)); + } + + public function test_data_transfer_object_from_array_with_models() + { + $post = Post::create([ + 'id' => 1, + 'title' => 'Lorem ipsum', + 'slug' => 'lorem-ipsum', + 'status' => PostStatus::Hidden->value, + ]); + + $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); + $this->assertIsArray($data->tags); + $this->assertContains('bar', $data->tags); + $this->assertTrue($data->post->is($post)); + } + + 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(); + + $mock->shouldReceive('route')->andReturn('example'); + $mock->shouldReceive('all')->andReturn([ + 'title' => 'Hello world', + 'tags' => '', + 'post_status' => PostStatus::Published->value, + ]); + $mock->shouldReceive('has')->withArgs(['post_status'])->andReturn(true); + $mock->shouldReceive('has')->withArgs(['postStatus'])->andReturn(true); + $mock->shouldReceive('has')->withArgs(['post'])->andReturn(false); + + $this->mock(Request::class, fn () => $mock); + + $data = map($mock)->to(CreatePostData::class); + + $this->assertFalse($data->filled('tags')); + $this->assertTrue($data->filled('post_status')); + $this->assertFalse($data->filled('post')); + } + + public function test_data_transfer_object_without_property_keys_normalisation_when_disabled_from_config() + { + config(['data-mapper.normalise_properties' => false]); + + $post = Post::create([ + 'id' => 2, + 'title' => 'Hello ipsum', + 'slug' => 'hello-ipsum', + 'status' => PostStatus::Hidden->value, + ]); + + $parentPost = Post::create([ + 'id' => 1, + 'title' => 'Lorem ipsum', + 'slug' => 'lorem-ipsum', + 'status' => PostStatus::Hidden->value, + ]); + + $data = map([ + 'post' => 2, + 'parent' => 1, + 'tags' => 'test,hello', + ])->to(UpdatePostData::class); + + $this->assertTrue($data->post?->is($post)); + $this->assertTrue($data->parent?->is($parentPost)); + } + + public function test_nested_data_transfer_objects_gets_the_nested_as_object_instance() + { + $this->markTestIncomplete('Need to create nested actions/DTOs'); + } + + public function test_data_transfer_object_does_not_take_route_bound_stuff() + { + $this->markTestIncomplete('Need to create nested actions/DTOs'); + } +} + +class CreatePostFormRequest extends FormRequest +{ + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'title' => ['string'], + 'tags' => ['string'], + 'post_status' => [Rule::enum(PostStatus::class)], + ]; + } +} diff --git a/tests/Integration/DataTransferObjectTest.php b/tests/Integration/DataTransferObjectTest.php deleted file mode 100644 index 86422bc..0000000 --- a/tests/Integration/DataTransferObjectTest.php +++ /dev/null @@ -1,294 +0,0 @@ -forceFill([ - 'id' => 1, - 'name' => 'Ruben', - 'email' => 'ruben@hello.com', - 'password' => '', - ]); - - Auth::shouldReceive('check')->andReturn(true); - Auth::shouldReceive('user')->andReturn($user); - - /** @var CreatePostFormRequest */ - $mock = Mockery::mock(app(CreatePostFormRequest::class))->makePartial(); - - $mock->shouldReceive('route')->andReturn('example'); - $mock->shouldReceive('validated')->andReturn([ - 'title' => 'Hello world', - 'tags' => ['foo', 'bar', 'test'], - 'subscribers' => 'hello@world.com,hola@mundo.com,', - 'post_status' => PostStatus::Published->value, - ]); - - // Not absolutely the same but does the job... - app()->bind(Request::class, fn () => $mock); - - $data = CreatePostData::fromRequest($mock); - - $this->assertTrue($data->postStatus instanceof PostStatus); - $this->assertEquals('Hello world', $data->title); - $this->assertIsArray($data->tags); - $this->assertContains('bar', $data->tags); - $this->assertTrue($data->subscribers instanceof Collection); - $this->assertContains('hello@world.com', $data->subscribers->all()); - $this->assertContains('hola@mundo.com', $data->subscribers->all()); - $this->assertContains('bar', $data->tags); - $this->assertTrue($user->is($data->currentUser)); - } - - public function testDataTransferObjectFromArrayWithModels() - { - $post = Post::create([ - 'id' => 1, - 'title' => 'Lorem ipsum', - 'slug' => 'lorem-ipsum', - 'status' => PostStatus::Hidden->value, - ]); - - $data = CreatePostData::fromArray([ - 'title' => 'Hello world', - 'tags' => 'foo,bar,test', - 'post_status' => PostStatus::Published->value, - 'post_id' => 1, - ]); - - $this->assertTrue($data->postStatus instanceof PostStatus); - $this->assertEquals('Hello world', $data->title); - $this->assertIsArray($data->tags); - $this->assertContains('bar', $data->tags); - $this->assertTrue($data->post->is($post)); - } - - public function testDataTransferObjectFilledViaRequest() - { - /** @var CreatePostFormRequest */ - $mock = Mockery::mock(app(Request::class))->makePartial(); - - $mock->shouldReceive('route')->andReturn('example'); - $mock->shouldReceive('all')->andReturn([ - 'title' => 'Hello world', - 'tags' => '', - 'post_status' => PostStatus::Published->value, - ]); - $mock->shouldReceive('has')->withArgs(['post_status'])->andReturn(true); - $mock->shouldReceive('has')->withArgs(['postStatus'])->andReturn(true); - $mock->shouldReceive('has')->withArgs(['post'])->andReturn(false); - - app()->bind(Request::class, fn () => $mock); - - $data = CreatePostData::fromRequest($mock); - - $this->assertFalse($data->filled('tags')); - $this->assertTrue($data->filled('post_status')); - $this->assertFalse($data->filled('post')); - } - - public function testDataTransferObjectWithoutPropertyKeysNormalisationWhenDisabledFromConfig() - { - config(['data-transfer-objects.normalise_properties' => false]); - - $post = Post::create([ - 'id' => 2, - 'title' => 'Hello ipsum', - 'slug' => 'hello-ipsum', - 'status' => PostStatus::Hidden->value, - ]); - - $parentPost = Post::create([ - 'id' => 1, - 'title' => 'Lorem ipsum', - 'slug' => 'lorem-ipsum', - 'status' => PostStatus::Hidden->value, - ]); - - $data = UpdatePostData::fromArray([ - 'post_id' => 2, - 'parent' => 1, - 'tags' => 'test,hello', - ]); - - $this->assertTrue($data->post_id?->is($post)); - $this->assertTrue($data->parent?->is($parentPost)); - } - - public function testDataTransferObjectWithDefaultValueAttribute() - { - $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($data->toArray()); - }); - - $response = $this->postJson('/posts', []); - - $response->assertJsonFragment([ - 'author' => $user->toArray(), - 'post' => $helloWorldPost->toArray(), - ]); - } - - public function testDataTransferObjectWithDefaultValueAttributeGetsBoundWhenOneIsSent() - { - $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($data->toArray()); - }); - - $response = $this->postJson('/posts/foo-bar', []); - - $response->assertJsonFragment([ - 'author' => $user->toArray(), - 'post' => $fooBarPost->toArray(), - ]); - } - - public function testDataTransferObjectWithMorphsGetsModelsBoundOfEachTypeSent() - { - $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' => '1, 1, 2', - // 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 testNestedDataTransferObjectsGetsTheNestedAsObjectInstance() - { - $this->markTestIncomplete('Need to create nested actions/DTOs'); - } - - public function testDataTransferObjectDoesNotTakeRouteBoundStuff() - { - $this->markTestIncomplete('Need to create nested actions/DTOs'); - } -} - -class CreatePostFormRequest extends FormRequest -{ - /** - * Get the validation rules that apply to the request. - * - * @return array - */ - public function rules() - { - return [ - 'title' => ['string'], - 'tags' => ['string'], - 'post_status' => [Rule::enum(PostStatus::class)], - ]; - } -} diff --git a/tests/Integration/DtoMakeCommandTest.php b/tests/Integration/DtoMakeCommandTest.php deleted file mode 100644 index 1909b7b..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 testMakeDataTransferObjectCommandWithEmptyRequestOptionCreatesTheFileWithValidatedRequest() - { - $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 testMakeDataTransferObjectCommandWithRequestOptionCreatesTheFileWithProperties() - { - $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 8e00169..0000000 --- a/tests/Integration/DtoTypescriptGenerateCommandTest.php +++ /dev/null @@ -1,64 +0,0 @@ - [ - 'output' => 'js', - // 'source' => 'tests/Fixtures', - 'filename' => 'types', - 'declarations' => false, - ], - ]); - } - - public function testDtoTypescriptGeneratesTypescriptTypesFile() - { - 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/MapperTest.php b/tests/MapperTest.php new file mode 100644 index 0000000..099a23d --- /dev/null +++ b/tests/MapperTest.php @@ -0,0 +1,142 @@ +create(); + + $result = map('1')->to(User::class); + + $this->assertInstanceOf(User::class, $result); + $this->assertEquals($user->email, $result->email); + } + + 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 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 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 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 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 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 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 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 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 test_map_strings_as_args_to_backed_enum_in_collection_of_backed_enum_instances() + { + $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); + } + + public function test_map_object_to_type_script_results_in_stringified_script_code() + { + $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); + } +} diff --git a/tests/ObjectMappingTest.php b/tests/ObjectMappingTest.php new file mode 100644 index 0000000..9e3aecf --- /dev/null +++ b/tests/ObjectMappingTest.php @@ -0,0 +1,38 @@ + '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 84% rename from tests/Integration/TestCase.php rename to tests/TestCase.php index 8d77350..d6361f4 100644 --- a/tests/Integration/TestCase.php +++ b/tests/TestCase.php @@ -1,8 +1,9 @@ '', ]); - $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'); } } diff --git a/tests/Unit/DataTransferObjectTest.php b/tests/Unit/DataTransferObjectTest.php index 4b4e59e..91dc4a5 100644 --- a/tests/Unit/DataTransferObjectTest.php +++ b/tests/Unit/DataTransferObjectTest.php @@ -1,47 +1,26 @@ shouldReceive('get')->andReturn(true); - - Container::getInstance()->bind('config', fn () => $mockedConfig); - - $mockedAuth = Mockery::mock(AuthManager::class); - - $mockedAuth->shouldReceive('check')->andReturn(false); - - Container::getInstance()->bind('auth', fn () => $mockedAuth); - - Container::getInstance()->bind('dto.context.booted', fn () => ''); - } - - public function testDataTransferObjectFromArray() + public function test_object_as_data_transfer_object_from_array() { - $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); @@ -50,27 +29,29 @@ public function testDataTransferObjectFromArray() $this->assertNull($data->post); } - public function testDataTransferObjectFromArrayDelimitedLists() + public function test_data_transfer_object_from_array_delimited_lists() { - $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); } - public function testDataTransferObjectFilledViaClassProperties() + public function test_data_transfer_object_filled_via_class_properties() { - $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')); @@ -79,19 +60,19 @@ public function testDataTransferObjectFilledViaClassProperties() $this->assertFalse($data->filled('author_email')); } - public function testDataTransferObjectWithDefaults() + public function test_data_transfer_object_with_defaults() { - $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); } - public function testDataTransferObjectArrayWithoutTypedPropertiesGetsThroughWithoutChanges() + public function test_data_transfer_object_array_without_typed_properties_gets_through_without_changes() { $helloTag = [ 'name' => 'Hello world', @@ -103,20 +84,20 @@ 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); } - public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() + public function test_data_transfer_object_array_properties_gets_mapped_as_collection() { $rubenUser = [ 'name' => 'Rubén Robles', @@ -128,7 +109,7 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() 'email' => 'taylor@hello.com', ]; - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'subscribers' => [ @@ -136,43 +117,43 @@ public function testDataTransferObjectArrayPropertiesGetsMappedAsCollection() $taylorUser, ], 'post_status' => PostStatus::Published->value, - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->subscribers instanceof Collection); $this->assertContains($rubenUser, $data->subscribers); $this->assertContains($taylorUser, $data->subscribers); } - public function testDataTransferObjectDatePropertiesGetMappedFromStringsIntoCarbonInstances() + public function test_data_transfer_object_date_properties_get_mapped_from_strings_into_carbon_instances() { - $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)); } - public function testDataTransferObjectDatePropertiesGetMappedFromJsonStringsIntoGenericObjects() + public function test_data_transfer_object_date_properties_get_mapped_from_json_strings_into_generic_objects() { - $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); } - public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGenericObjects() + public function test_data_transfer_object_date_properties_get_mapped_from_arrays_into_generic_objects() { - $data = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -193,15 +174,15 @@ public function testDataTransferObjectDatePropertiesGetMappedFromArraysIntoGener ], ], ], - ]); + ])->to(CreatePostData::class); $this->assertTrue($data->content instanceof \stdClass); $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 = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -209,7 +190,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); @@ -217,9 +198,9 @@ 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 = CreatePostData::fromArray([ + $data = map([ 'title' => 'Hello world', 'tags' => '', 'post_status' => PostStatus::Published->value, @@ -227,16 +208,16 @@ 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); - $this->assertIsString($data->dates->first()); + $this->assertTrue($data->dates->first() instanceof Carbon); + $this->assertTrue($data->dates->last() instanceof Carbon); } - public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFromArray() + public function test_data_transfer_object_sent_into_another_as_collected_will_be_mapped_from_array() { - $data = CreateManyPostData::fromArray([ + $data = map([ 'posts' => [ [ 'title' => 'Hello world', @@ -257,7 +238,7 @@ public function testDataTransferObjectSentIntoAnotherAsCollectedWillBeMappedFrom ], ], ], - ]); + ])->to(CreateManyPostData::class); $this->assertInstanceOf(Collection::class, $data->posts); @@ -272,15 +253,15 @@ 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 = CreateComment::fromArray([ + $data = map([ 'content' => 'hello world', 'tags' => [ 'hello' => 'world', - 'foo' => 'bar' - ] - ]); + 'foo' => 'bar', + ], + ])->to(CreateComment::class); $this->assertArrayHasKey('hello', $data->tags); $this->assertArrayHasKey('foo', $data->tags); diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php new file mode 100644 index 0000000..a0514a1 --- /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/tests/Integration/ValidatedDataTransferObjectTest.php b/tests/ValidatedDataTransferObjectTest.php similarity index 76% rename from tests/Integration/ValidatedDataTransferObjectTest.php rename to tests/ValidatedDataTransferObjectTest.php index 3e05b87..925141f 100644 --- a/tests/Integration/ValidatedDataTransferObjectTest.php +++ b/tests/ValidatedDataTransferObjectTest.php @@ -1,6 +1,6 @@ withoutExceptionHandling(); } - public function testValidatedDataTransferObjectGetsRouteBoundModel() + public function test_validated_data_transfer_object_gets_route_bound_model() { $post = PostFactory::new()->hasAttached( TagFactory::new()->count(2) @@ -32,7 +34,7 @@ public function testValidatedDataTransferObjectGetsRouteBoundModel() ], true); } - public function testValidatedDataTransferObjectGetsValidatedOnlyParameters() + public function test_validated_data_transfer_object_gets_validated_only_parameters() { PostFactory::new()->create(); @@ -58,28 +60,29 @@ public function testValidatedDataTransferObjectGetsValidatedOnlyParameters() 'slug' => $secondTag->slug, ], ], - 'published_at' => '2023-09-06T17:35:53.000000Z', + 'publishedAt' => '2023-09-06T17:35:53.000000Z', ], 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) )->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()); } - public function testDataTransferObjectWithModelSentDoesNotRunQueriesToFetchItAgain() + public function test_data_transfer_object_with_model_sent_does_not_run_queries_to_fetch_it_again() { $post = PostFactory::new()->make(); @@ -87,15 +90,15 @@ 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)); } - public function testDataTransferObjectCanBeSerializedAndDeserialized() + public function test_data_transfer_object_can_be_serialized_and_deserialized() { $this->withoutExceptionHandling(); @@ -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/workbench/app/DataObjects/CreateUserData.php b/workbench/app/DataObjects/CreateUserData.php new file mode 100644 index 0000000..e95d79c --- /dev/null +++ b/workbench/app/DataObjects/CreateUserData.php @@ -0,0 +1,11 @@ + $posts diff --git a/workbench/app/DataTransferObjects/CreatePostData.php b/workbench/app/DataTransferObjects/CreatePostData.php index c59d013..570b3c3 100644 --- a/workbench/app/DataTransferObjects/CreatePostData.php +++ b/workbench/app/DataTransferObjects/CreatePostData.php @@ -2,15 +2,16 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDataMapper\Attributes\Authenticated; +use OpenSoutheners\LaravelDataMapper\Contracts\RouteTransferableObject; use stdClass; use Workbench\App\Enums\PostStatus; use Workbench\App\Models\Post; +use Workbench\App\Models\User; -class CreatePostData extends DataTransferObject +class CreatePostData implements RouteTransferableObject { public mixed $authorEmail = 'me@d8vjork.com'; @@ -19,28 +20,23 @@ class CreatePostData extends DataTransferObject */ public function __construct( public string $title, - public array|null $tags, + public ?array $tags = null, public PostStatus $postStatus, public ?Post $post = null, 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->authorEmail = $authorEmail; - } - - /** - * Add default data to data transfer object. - */ - public function withDefaults(): void - { - if (empty($this->tags)) { + if (count($this->tags) === 0) { $this->tags = ['generic', 'post']; } + + $this->authorEmail = $authorEmail; } } diff --git a/workbench/app/DataTransferObjects/UpdatePostData.php b/workbench/app/DataTransferObjects/UpdatePostData.php index 65e5601..c44d731 100644 --- a/workbench/app/DataTransferObjects/UpdatePostData.php +++ b/workbench/app/DataTransferObjects/UpdatePostData.php @@ -2,16 +2,15 @@ namespace Workbench\App\DataTransferObjects; -use OpenSoutheners\LaravelDto\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostData extends DataTransferObject +class UpdatePostData { /** * @param string[] $tags */ public function __construct( - public ?Post $post_id, + public ?Post $post, public ?Post $parent = null, public array|string|null $country = null, public array $tags = [], diff --git a/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php b/workbench/app/DataTransferObjects/UpdatePostWithDefaultData.php index 5166944..4698558 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\LaravelDataMapper\Attributes\Authenticated; +use OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel; +use OpenSoutheners\LaravelDataMapper\Contracts\RouteTransferableObject; use Workbench\App\Models\Post; use Workbench\App\Models\Tag; use Workbench\App\Models\User; -class UpdatePostWithDefaultData extends DataTransferObject +class UpdatePostWithDefaultData implements RouteTransferableObject { /** * @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..6fecec3 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithRouteBindingData.php @@ -3,38 +3,36 @@ 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\BindModel; -use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDataMapper\Attributes\AsType; +use OpenSoutheners\LaravelDataMapper\Attributes\Authenticated; +use OpenSoutheners\LaravelDataMapper\Attributes\ModelWith; +use OpenSoutheners\LaravelDataMapper\Attributes\Validate; +use OpenSoutheners\LaravelDataMapper\Contracts\RouteTransferableObject; 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 RouteTransferableObject { /** * @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..7aad536 100644 --- a/workbench/app/DataTransferObjects/UpdatePostWithTags.php +++ b/workbench/app/DataTransferObjects/UpdatePostWithTags.php @@ -3,15 +3,13 @@ namespace Workbench\App\DataTransferObjects; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\DataTransferObject; use Workbench\App\Models\Post; -class UpdatePostWithTags extends DataTransferObject +class UpdatePostWithTags { 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 51a0fca..60d82d3 100644 --- a/workbench/app/DataTransferObjects/UpdateTagData.php +++ b/workbench/app/DataTransferObjects/UpdateTagData.php @@ -2,41 +2,32 @@ namespace Workbench\App\DataTransferObjects; -use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Support\Collection; -use OpenSoutheners\LaravelDto\Attributes\BindModel; -use OpenSoutheners\LaravelDto\Attributes\WithDefaultValue; -use OpenSoutheners\LaravelDto\Contracts\ValidatedDataTransferObject; -use OpenSoutheners\LaravelDto\DataTransferObject; +use OpenSoutheners\LaravelDataMapper\Attributes\Authenticated; +use OpenSoutheners\LaravelDataMapper\Attributes\ResolveModel; +use OpenSoutheners\LaravelDataMapper\Attributes\Validate; +use OpenSoutheners\LaravelDataMapper\Contracts\RouteTransferableObject; 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 RouteTransferableObject { /** - * @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( - #[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/app/Models/User.php b/workbench/app/Models/User.php index 950b194..3f4f08d 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..f46686d --- /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, + ]); + } +} 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 25c3438..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; /* @@ -17,11 +16,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');