Skip to content

Feature/additional attributes #14

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
380 changes: 242 additions & 138 deletions clover.xml

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions docs/NameMapper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Name Mapper
=

Sometimes we could be expecting payload with different letter case or different naming convention.
In such cases, we can use the `NameMapper` attribute to map the property to the correct key in the data array.

```php
use Nuxtifyts\PhpDto\Data;
use Nuxtifyts\PhpDto\Attributes\Class\MapName;
use Nuxtifyts\PhpDto\Enums\LetterCase;

#[MapName(from: [LetterCase::KEBAB, LetterCase::SNAKE])]
final readonly class UserData extends Data
{
public function __construct(
public string $firstName,
public string $lastName
) {}
}
```

In the above example, passed data with keys `letter_case` and `letter-case` will be mapped to `letterCase` (By default),
and all of these keys will be transformed to the selected letter case.

```php
$user = UserData::from([ 'first-name' => 'John', 'last_name': 'Doe' ]);
```

> **Note:** The `MapName` attribute is applied on every key in the data array.

`MapName` attribute accepts these params:

| Param | Type | Description | Default |
|--------|----------------------------------|----------------------------------|-------------------|
| `from` | `LetterCase`\|`list<LetterCase>` | List of letter cases to map from | - |
| `to` | `LetterCase` | Letter case to map to | LetterCase::CAMEL |
29 changes: 29 additions & 0 deletions docs/PropertyAttributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Property Attributes

In order to provide more functionality to your DTOs, you can use the following attributes:
- [Computed](#Computed) - To define a property that is computed from other properties.
- [Hidden](#Hidden) - To define a property that should not be serialized.
- [Aliases](#Aliases) - To define aliases for a property.
- [DefaultsTo](#DefaultsTo) - To define a default value for a property using a fallback resolver.
- [CipherTarget](#CipherTarget) - To define a property that should be encrypted/decrypted.
Expand Down Expand Up @@ -33,6 +34,29 @@ final readonly class PersonData extends Data

This will make the DTO aware of the `fullName` property, and it will not be serialized or deserialized.

Hidden
-

Sometimes, we may need to specify that a property should not be serialized.

This can be done using the `Hidden` attribute.

```php
use Nuxtifyts\PhpDto\Attributes\Property\Hidden;
use Nuxtifyts\PhpDto\Data;

final readonly class PersonData extends Data
{
public function __construct(
public string $firstName,
#[Hidden]
public string $lastName
) {}
}
```

When serializing the DTO, the `lastName` property will not be included in the output.

Aliases
-

Expand All @@ -56,6 +80,11 @@ final readonly class PersonData extends Data

This will make it possible to hydrate properties from multiple array keys.

> **Note:** Sometimes, we may want to apply the `Aliases` attribute to the whole class,
> in case we want to transform letter cases of all the keys in data array.
> In such cases, we can use the [MapName](https://github.com/nuxtifyts/php-dto/blob/main/docs/NameMapper.md)
> attribute.

CipherTarget
-

Expand Down
1 change: 1 addition & 0 deletions docs/Quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ can be found here:
- [Supported Types](https://github.com/nuxtifyts/php-dto/blob/main/docs/SupportedTypes.md)
- [Normalizers](https://github.com/nuxtifyts/php-dto/blob/main/docs/Normalizers.md)
- [Property Attributes](https://github.com/nuxtifyts/php-dto/blob/main/docs/PropertyAttributes.md)
- [Name Mapper](https://github.com/nuxtifyts/php-dto/blob/main/docs/NameMapper.md)
- [Data Refiners](https://github.com/nuxtifyts/php-dto/blob/main/docs/DataRefiners.md)
- [Empty Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/EmptyData.md)
- [Cloneable Data](https://github.com/nuxtifyts/php-dto/blob/main/docs/CloneableData.md)
Expand Down
19 changes: 19 additions & 0 deletions src/Attributes/Class/MapName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Nuxtifyts\PhpDto\Attributes\Class;

use Attribute;
use Nuxtifyts\PhpDto\Enums\LetterCase;

#[Attribute(Attribute::TARGET_CLASS)]
class MapName
{
/**
* @param LetterCase|list<LetterCase> $from
*/
public function __construct(
protected(set) LetterCase|array $from,
protected(set) LetterCase $to = LetterCase::CAMEL
) {
}
}
10 changes: 10 additions & 0 deletions src/Attributes/Property/Hidden.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Nuxtifyts\PhpDto\Attributes\Property;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
class Hidden
{
}
2 changes: 1 addition & 1 deletion src/Concerns/BaseData.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ final public function jsonSerialize(): array

$serializedData = [];
foreach ($context->properties as $propertyContext) {
if ($propertyContext->isComputed) {
if ($propertyContext->isComputed || $propertyContext->isHidden) {
continue;
}

Expand Down
14 changes: 14 additions & 0 deletions src/Contexts/ClassContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Nuxtifyts\PhpDto\Contexts;

use Nuxtifyts\PhpDto\Attributes\Class\MapName;
use Nuxtifyts\PhpDto\Attributes\Class\WithNormalizer;
use Nuxtifyts\PhpDto\Contexts\ClassContext\NameMapperConfig;
use Nuxtifyts\PhpDto\Data;
use Nuxtifyts\PhpDto\Exceptions\DataCreationException;
use Nuxtifyts\PhpDto\Exceptions\UnsupportedTypeException;
Expand Down Expand Up @@ -36,6 +38,8 @@ class ClassContext
/** @var array<array-key, class-string<Normalizer>> */
private(set) array $normalizers = [];

private(set) ?NameMapperConfig $nameMapperConfig = null;

/**
* @param ReflectionClass<T> $reflection
*
Expand Down Expand Up @@ -118,6 +122,16 @@ private function syncClassAttributes(): void
...$withNormalizerAttribute->newInstance()->classStrings
]);
}

if ($nameMapperAttribute = $this->reflection->getAttributes(MapName::class)[0] ?? null) {
/** @var ReflectionAttribute<MapName> $nameMapperAttribute */
$instance = $nameMapperAttribute->newInstance();

$this->nameMapperConfig = new NameMapperConfig(
from: $instance->from,
to: $instance->to
);
}
}

/**
Expand Down
37 changes: 37 additions & 0 deletions src/Contexts/ClassContext/NameMapperConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Nuxtifyts\PhpDto\Contexts\ClassContext;

use Nuxtifyts\PhpDto\Enums\LetterCase;
use Nuxtifyts\PhpDto\Support\Str;

readonly class NameMapperConfig
{
/** @var list<LetterCase> */
protected array $from;

/**
* @param LetterCase|list<LetterCase> $from
*/
public function __construct(
LetterCase|array $from,
protected LetterCase $to
) {
$this->from = is_array($from) ? $from : [$from];
}

public function transform(string $value): string|false
{
if (Str::validateLetterCase($value, $this->to)) {
return $value;
}

foreach ($this->from as $letterCase) {
if (Str::validateLetterCase($value, $letterCase)) {
return Str::transformLetterCase($value, $letterCase, $this->to);
}
}

return false;
}
}
4 changes: 4 additions & 0 deletions src/Contexts/PropertyContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Nuxtifyts\PhpDto\Attributes\Property\CipherTarget;
use Nuxtifyts\PhpDto\Attributes\Property\Computed;
use Nuxtifyts\PhpDto\Attributes\Property\DefaultsTo;
use Nuxtifyts\PhpDto\Attributes\Property\Hidden;
use Nuxtifyts\PhpDto\Attributes\Property\WithRefiner;
use Nuxtifyts\PhpDto\Contexts\Concerns\HasTypes;
use Nuxtifyts\PhpDto\Data;
Expand Down Expand Up @@ -51,6 +52,8 @@ class PropertyContext

private(set) bool $isComputed = false;

private(set) bool $isHidden = false;

private(set) ?CipherConfig $cipherConfig = null;

private(set) ?FallbackConfig $fallbackConfig = null;
Expand Down Expand Up @@ -104,6 +107,7 @@ private static function getKey(ReflectionProperty $property): string
private function syncPropertyAttributes(): void
{
$this->isComputed = !empty($this->reflection->getAttributes(Computed::class));
$this->isHidden = !empty($this->reflection->getAttributes(Hidden::class));

foreach ($this->reflection->getAttributes(WithRefiner::class) as $withRefinerAttribute) {
/** @var ReflectionAttribute<WithRefiner> $withRefinerAttribute */
Expand Down
11 changes: 11 additions & 0 deletions src/Enums/LetterCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Nuxtifyts\PhpDto\Enums;

enum LetterCase
{
case CAMEL;
case SNAKE;
case KEBAB;
case PASCAL;
}
3 changes: 3 additions & 0 deletions src/Pipelines/DeserializePipeline/DeserializePipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Nuxtifyts\PhpDto\Pipelines\DeserializePipeline;

use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\DecipherDataPipe;
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\MapNamesPipe;
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\RefineDataPipe;
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveDefaultDataPipe;
use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes\ResolveValuesFromAliasesPipe;
Expand All @@ -17,6 +18,7 @@ public static function hydrateFromArray(): self
{
return new DeserializePipeline(DeserializePipelinePassable::class)
->through(ResolveValuesFromAliasesPipe::class)
->through(MapNamesPipe::class)
->through(RefineDataPipe::class)
->through(DecipherDataPipe::class)
->through(ResolveDefaultDataPipe::class);
Expand All @@ -30,6 +32,7 @@ public static function createFromArray(): self
{
return new DeserializePipeline(DeserializePipelinePassable::class)
->through(ResolveValuesFromAliasesPipe::class)
->through(MapNamesPipe::class)
->through(RefineDataPipe::class)
->through(ResolveDefaultDataPipe::class);
}
Expand Down
35 changes: 35 additions & 0 deletions src/Pipelines/DeserializePipeline/Pipes/MapNamesPipe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\Pipes;

use Nuxtifyts\PhpDto\Pipelines\DeserializePipeline\DeserializePipelinePassable;
use Nuxtifyts\PhpDto\Support\Passable;
use Nuxtifyts\PhpDto\Support\Pipe;

/**
* @extends Pipe<DeserializePipelinePassable>
*/
readonly class MapNamesPipe extends Pipe
{
public function handle(Passable $passable): DeserializePipelinePassable
{
if (!$passable->classContext->nameMapperConfig) {
return $passable;
}

$data = $passable->data;

foreach ($data as $key => $value) {
$newKey = $passable->classContext->nameMapperConfig->transform($key);

if ($newKey === false || $newKey === $key) {
continue;
}

$data[$newKey] = $value;
unset($data[$key]);
}

return $passable->with(data: $data);
}
}
92 changes: 92 additions & 0 deletions src/Support/Str.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

namespace Nuxtifyts\PhpDto\Support;

use Nuxtifyts\PhpDto\Enums\LetterCase;

final readonly class Str
{
public static function validateLetterCase(string $value, LetterCase $letterCase): bool
{
return match ($letterCase) {
LetterCase::CAMEL => self::isCamelCase($value),
LetterCase::SNAKE => self::isSnakeCase($value),
LetterCase::KEBAB => self::isKebabCase($value),
LetterCase::PASCAL => self::isPascalCase($value),
};
}

public static function isCamelCase(string $value): bool
{
return preg_match('/^[a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1;
}

public static function isSnakeCase(string $value): bool
{
return preg_match('/^[a-z]+(?:_[a-z]+)*$/', $value) === 1;
}

public static function isKebabCase(string $value): bool
{
return preg_match('/^[a-z]+(?:-[a-z]+)*$/', $value) === 1;
}

public static function isPascalCase(string $value): bool
{
return preg_match('/^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/', $value) === 1;
}

public static function transformLetterCase(
string $value,
LetterCase $from,
LetterCase $to
): string {
if ($from === $to) {
return $value;
}

$value = match ($from) {
LetterCase::CAMEL => self::camelToSnake($value),
LetterCase::SNAKE => self::snakeToCamel($value),
LetterCase::KEBAB => self::kebabToCamel($value),
LetterCase::PASCAL => self::pascalToSnake($value),
};

return match ($to) {
LetterCase::CAMEL => self::snakeToCamel($value),
LetterCase::SNAKE => $value,
LetterCase::KEBAB => self::camelToKebab($value),
LetterCase::PASCAL => self::snakeToPascal($value),
};
}

public static function camelToSnake(string $value): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $value) ?? '');
}

public static function snakeToCamel(string $value): string
{
return lcfirst(str_replace('_', '', ucwords($value, '_')));
}

public static function kebabToCamel(string $value): string
{
return lcfirst(str_replace('-', '', ucwords($value, '-')));
}

public static function pascalToSnake(string $value): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $value) ?? '');
}

public static function snakeToPascal(string $value): string
{
return str_replace('_', '', ucwords($value, '_'));
}

public static function camelToKebab(string $value): string
{
return strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $value) ?? '');
}
}
Loading
Loading