diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a76dd83f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 + +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..e7e28b90 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.3.5 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml index a83d7080..4917b37e 100644 --- a/.github/workflows/php-cs-fixer.yml +++ b/.github/workflows/php-cs-fixer.yml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 5e06f4a4..b6b073e1 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,7 @@ jobs: name: phpstan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -25,7 +25,7 @@ jobs: coverage: none - name: Install composer dependencies - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@v2 - name: Run PHPStan run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 09cf5311..6a5a68e5 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index fa56639f..b20f3b6f 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: main diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fa5f5b..de9dc76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `laravel-data` will be documented in this file. +## 2.1.0 - 2022-12-07 + +- Stop using custom pipeline when creating data magically from requests +- set timezones in datetime casts (#287 ) + ## 2.0.16 - 2022-11-18 - add support for optional properties in TypeScript transformer (#153) diff --git a/docs/advanced-usage/working-with-dates.md b/docs/advanced-usage/working-with-dates.md index 13e0fc64..0ae42544 100644 --- a/docs/advanced-usage/working-with-dates.md +++ b/docs/advanced-usage/working-with-dates.md @@ -56,6 +56,17 @@ Now when casting a date, a valid format will be searched. When none can be found When a transformers hasn't explicitly stated it's format, the first format within the array is used. +## Casting dates in a different time zone + +Sometimes a date can be in a different timezone than the timezone you application uses. For example, if your application uses `Europe/Brussels` but your date is in `UTC`: + +```php +#[WithCast(DateTimeInterfaceCast::class, timeZone: 'UTC')] +public DateTime $date +``` + +The date will be created with the `UTC` timezone but will be the same as in the `Europe/Brussels` timezone. + ## Changing time zones When casting a date you may want to set an alternative timezone this can be achieved as such: @@ -65,6 +76,8 @@ When casting a date you may want to set an alternative timezone this can be achi public DateTime $date ``` +In this case the time will be transformed, if our original time was in `UTC` then one or two hours (depending on summer time) will be added. + You can also change the timezone of a property which is getting transformed: ```php diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e57fa80c..90f064d0 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -99,7 +99,7 @@ That's because the status property expects a `PostStatus` enum object, but it ge ```php class PostStatusCast implements Cast { - public function cast(DataProperty $property, mixed $value): PostStatus + public function cast(DataProperty $property, mixed $value, array $context): PostStatus { return PostStatus::from($value); } diff --git a/docs/questions-issues.md b/docs/questions-issues.md index 05a17931..102de060 100644 --- a/docs/questions-issues.md +++ b/docs/questions-issues.md @@ -3,6 +3,6 @@ title: Questions and issues weight: 5 --- -Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving Laravel Event Sourcing? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-data/issues), we'll try to address it as soon as possible. +Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving Laravel Data? Feel free to [create an issue on GitHub](https://github.com/spatie/laravel-data/issues), we'll try to address it as soon as possible. If you've found a bug regarding security please mail [freek@spatie.be](mailto:freek@spatie.be) instead of using the issue tracker. diff --git a/src/Casts/DateTimeInterfaceCast.php b/src/Casts/DateTimeInterfaceCast.php index 64a1d40c..26584da6 100644 --- a/src/Casts/DateTimeInterfaceCast.php +++ b/src/Casts/DateTimeInterfaceCast.php @@ -12,7 +12,8 @@ class DateTimeInterfaceCast implements Cast public function __construct( protected null|string|array $format = null, protected ?string $type = null, - protected ?string $setTimeZone = null + protected ?string $setTimeZone = null, + protected ?string $timeZone = null ) { } @@ -28,7 +29,11 @@ public function cast(DataProperty $property, mixed $value, array $context): Date /** @var DateTimeInterface|null $datetime */ $datetime = $formats - ->map(fn (string $format) => rescue(fn () => $type::createFromFormat($format, $value), report: false)) + ->map(fn (string $format) => rescue(fn () => $type::createFromFormat( + $format, + $value, + isset($this->timeZone) ? new DateTimeZone($this->timeZone) : null + ), report: false)) ->first(fn ($value) => (bool) $value); if (! $datetime) { diff --git a/src/Resolvers/DataFromArrayResolver.php b/src/Resolvers/DataFromArrayResolver.php index 2c5a1020..650e60dc 100644 --- a/src/Resolvers/DataFromArrayResolver.php +++ b/src/Resolvers/DataFromArrayResolver.php @@ -40,7 +40,10 @@ public function execute(string $class, Collection $properties): BaseData $dataClass ->properties ->filter( - fn (DataProperty $property) => ! $property->isPromoted && $properties->has($property->name) + fn (DataProperty $property) => + ! $property->isPromoted && + ! $property->isReadonly && + $properties->has($property->name) ) ->each(function (DataProperty $property) use ($properties, $data) { $data->{$property->name} = $properties->get($property->name); diff --git a/src/Resolvers/DataFromSomethingResolver.php b/src/Resolvers/DataFromSomethingResolver.php index fcdec677..ce75e5cb 100644 --- a/src/Resolvers/DataFromSomethingResolver.php +++ b/src/Resolvers/DataFromSomethingResolver.php @@ -5,10 +5,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Spatie\LaravelData\Contracts\BaseData; -use Spatie\LaravelData\DataPipeline; -use Spatie\LaravelData\DataPipes\AuthorizedDataPipe; -use Spatie\LaravelData\DataPipes\MapPropertiesDataPipe; -use Spatie\LaravelData\DataPipes\ValidatePropertiesDataPipe; use Spatie\LaravelData\Normalizers\ArrayableNormalizer; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\DataMethod; @@ -92,12 +88,9 @@ protected function createFromCustomCreationMethod(string $class, array $payloads foreach ($payloads as $payload) { if ($payload instanceof Request) { - DataPipeline::create() + $class::pipeline() ->normalizer(ArrayableNormalizer::class) ->into($class) - ->through(AuthorizedDataPipe::class) - ->through(MapPropertiesDataPipe::class) - ->through(ValidatePropertiesDataPipe::class) ->using($payload) ->execute(); } diff --git a/src/Support/DataClass.php b/src/Support/DataClass.php index 2d4ba7c1..bc16b1c0 100644 --- a/src/Support/DataClass.php +++ b/src/Support/DataClass.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; @@ -31,6 +32,7 @@ public function __construct( public readonly Collection $properties, public readonly Collection $methods, public readonly ?DataMethod $constructorMethod, + public readonly bool $isReadonly, public readonly bool $appendable, public readonly bool $includeable, public readonly bool $responsable, @@ -43,9 +45,9 @@ public function __construct( public static function create(ReflectionClass $class): self { - $attributes = collect($class->getAttributes())->map( - fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance() - ); + $attributes = collect($class->getAttributes()) + ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) + ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); $methods = collect($class->getMethods()); @@ -62,6 +64,7 @@ public static function create(ReflectionClass $class): self properties: $properties, methods: self::resolveMethods($class), constructorMethod: DataMethod::createConstructor($constructor, $properties), + isReadonly: method_exists($class, 'isReadOnly') && $class->isReadOnly(), appendable: $class->implementsInterface(AppendableData::class), includeable: $class->implementsInterface(IncludeableData::class), responsable: $class->implementsInterface(ResponsableData::class), diff --git a/src/Support/DataProperty.php b/src/Support/DataProperty.php index 58d9112b..19b1bc93 100644 --- a/src/Support/DataProperty.php +++ b/src/Support/DataProperty.php @@ -3,6 +3,7 @@ namespace Spatie\LaravelData\Support; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use ReflectionAttribute; use ReflectionProperty; use Spatie\LaravelData\Attributes\WithCast; @@ -24,6 +25,7 @@ public function __construct( public readonly DataType $type, public readonly bool $validate, public readonly bool $isPromoted, + public readonly bool $isReadonly, public readonly bool $hasDefaultValue, public readonly mixed $defaultValue, public readonly ?Cast $cast, @@ -41,9 +43,9 @@ public static function create( ?NameMapper $classInputNameMapper = null, ?NameMapper $classOutputNameMapper = null, ): self { - $attributes = collect($property->getAttributes())->map( - fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance() - ); + $attributes = collect($property->getAttributes()) + ->filter(fn (ReflectionAttribute $reflectionAttribute) => class_exists($reflectionAttribute->getName())) + ->map(fn (ReflectionAttribute $reflectionAttribute) => $reflectionAttribute->newInstance()); $mappers = NameMappersResolver::create()->execute($attributes); @@ -65,6 +67,7 @@ className: $property->class, type: DataType::create($property), validate: ! $attributes->contains(fn (object $attribute) => $attribute instanceof WithoutValidation), isPromoted: $property->isPromoted(), + isReadonly: $property->isReadOnly(), hasDefaultValue: $property->isPromoted() ? $hasDefaultValue : $property->hasDefaultValue(), defaultValue: $property->isPromoted() ? $defaultValue : $property->getDefaultValue(), cast: $attributes->first(fn (object $attribute) => $attribute instanceof WithCast)?->get(), diff --git a/tests/Casts/DateTimeInterfaceCastTest.php b/tests/Casts/DateTimeInterfaceCastTest.php index 449a2782..8301a65c 100644 --- a/tests/Casts/DateTimeInterfaceCastTest.php +++ b/tests/Casts/DateTimeInterfaceCastTest.php @@ -98,35 +98,81 @@ public DateTimeImmutable $dateTimeImmutable; }; - expect( - $caster->cast( - DataProperty::create(new ReflectionProperty($class, 'carbon')), - '19-05-1994 00:00:00', - [] - )->getTimezone() - )->toEqual(CarbonTimeZone::create('Europe/Brussels')); + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'carbon')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 02:00:00') + ->getTimezone()->toEqual(CarbonTimeZone::create('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'carbonImmutable')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 02:00:00') + ->getTimezone()->toEqual(CarbonTimeZone::create('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'dateTime')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 02:00:00') + ->getTimezone()->toEqual(new DateTimeZone('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'dateTimeImmutable')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 02:00:00') + ->getTimezone()->toEqual(new DateTimeZone('Europe/Brussels')); +}); - expect( - $caster->cast( - DataProperty::create(new ReflectionProperty($class, 'carbonImmutable')), - '19-05-1994 00:00:00', - [] - )->getTimezone() - )->toEqual(CarbonTimeZone::create('Europe/Brussels')); +it('can cast date times with a timezone', function () { + $caster = new DateTimeInterfaceCast('d-m-Y H:i:s', timeZone: 'Europe/Brussels'); - expect( - $caster->cast( - DataProperty::create(new ReflectionProperty($class, 'dateTime')), - '19-05-1994 00:00:00', - [] - )->getTimezone() - )->toEqual(new DateTimeZone('Europe/Brussels')); + $class = new class () { + public Carbon $carbon; - expect( - $caster->cast( - DataProperty::create(new ReflectionProperty($class, 'dateTimeImmutable')), - '19-05-1994 00:00:00', - [] - )->getTimezone() - )->toEqual(new DateTimeZone('Europe/Brussels')); + public CarbonImmutable $carbonImmutable; + + public DateTime $dateTime; + + public DateTimeImmutable $dateTimeImmutable; + }; + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'carbon')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 00:00:00') + ->getTimezone()->toEqual(CarbonTimeZone::create('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'carbonImmutable')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 00:00:00') + ->getTimezone()->toEqual(CarbonTimeZone::create('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'dateTime')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 00:00:00') + ->getTimezone()->toEqual(new DateTimeZone('Europe/Brussels')); + + expect($caster->cast( + DataProperty::create(new ReflectionProperty($class, 'dateTimeImmutable')), + '19-05-1994 00:00:00', + [] + )) + ->format('Y-m-d H:i:s')->toEqual('1994-05-19 00:00:00') + ->getTimezone()->toEqual(new DateTimeZone('Europe/Brussels')); }); diff --git a/tests/Support/DataClassTest.php b/tests/Support/DataClassTest.php index 730a42d9..ea32dbe5 100644 --- a/tests/Support/DataClassTest.php +++ b/tests/Support/DataClassTest.php @@ -1,9 +1,11 @@ hasDefaultValue->toBeTrue() ->defaultValue->toEqual('Hello Again'); }); + +it('wont throw an error if a non existing attribute is used on a data class', function () { + expect(PhpStormClassAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') + ->and(NonExistingAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') + ->and(PhpStormClassAttributeData::from((object)['property' => 'hello'])->property)->toEqual('hello') + ->and(PhpStormClassAttributeData::from('{"property": "hello"}')->property)->toEqual('hello') + ->and(ModelWithPhpStormAttributeData::from((new DummyModel)->fill(['id' => 1]))->id)->toEqual(1); +}); + +#[\JetBrains\PhpStorm\Immutable] +class PhpStormClassAttributeData extends Data +{ + public readonly string $property; + + public function __construct(string $property) + { + $this->property = $property; + } +} + +#[\Foo\Bar] +class NonExistingAttributeData extends Data +{ + public readonly string $property; + + public function __construct(string $property) + { + $this->property = $property; + } +} + +#[\JetBrains\PhpStorm\Immutable] +class ModelWithPhpStormAttributeData extends Data +{ + public function __construct( + public int $id + ) { + } + + public static function fromDummyModel(DummyModel $model) + { + return new self($model->id); + } +} diff --git a/tests/Support/DataPropertyTest.php b/tests/Support/DataPropertyTest.php index 2ecb7424..50057a72 100644 --- a/tests/Support/DataPropertyTest.php +++ b/tests/Support/DataPropertyTest.php @@ -7,8 +7,10 @@ use Spatie\LaravelData\Attributes\WithoutValidation; use Spatie\LaravelData\Attributes\WithTransformer; use Spatie\LaravelData\Casts\DateTimeInterfaceCast; +use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\Support\DataProperty; +use Spatie\LaravelData\Tests\Fakes\DummyModel; use Spatie\LaravelData\Tests\Fakes\SimpleData; use Spatie\LaravelData\Transformers\DateTimeInterfaceTransformer; @@ -126,3 +128,74 @@ public function __construct( })->validate )->toBeFalse(); }); + +it('wont throw an error if non existing attribute is used on a data class property', function () { + expect(NonExistingPropertyAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') + ->and(PhpStormAttributeData::from(['property' => 'hello'])->property)->toEqual('hello') + ->and(PhpStormAttributeData::from('{"property": "hello"}')->property)->toEqual('hello') + ->and(PhpStormAttributeData::from((object) ['property' => 'hello'])->property)->toEqual('hello') + ->and(ModelWithPhpStormAttributePropertyData::from((new DummyModel)->fill(['id' => 1]))->id)->toEqual(1) + ->and(ModelWithPromotedPhpStormAttributePropertyData::from((new DummyModel)->fill(['id' => 1]))->id)->toEqual(1); +}); + +class NonExistingPropertyAttributeData extends Data +{ + #[\Foo\Bar] + public readonly string $property; + + public function __construct(string $property) + { + $this->property = $property; + } +} + +class PhpStormAttributeData extends Data +{ + #[\JetBrains\PhpStorm\Immutable] + public readonly string $property; + + public function __construct(string $property) + { + $this->property = $property; + } +} + +class PromotedPhpStormAttributeData extends Data +{ + public function __construct( + #[\JetBrains\PhpStorm\Immutable] + public readonly string $property) + { + // + } +} + +class ModelWithPhpStormAttributePropertyData extends Data +{ + #[\JetBrains\PhpStorm\Immutable] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function fromDummyModel(DummyModel $model) + { + return new self($model->id); + } +} + +class ModelWithPromotedPhpStormAttributePropertyData extends Data +{ + public function __construct( + #[\JetBrains\PhpStorm\Immutable] + public int $id + ) { + } + + public static function fromDummyModel(DummyModel $model) + { + return new self($model->id); + } +}