Skip to content

Commit

Permalink
Merge pull request #5 from whitecube/fix-cast-set-on-timestamps
Browse files Browse the repository at this point in the history
Fixed cast set on timestamp attributes
  • Loading branch information
toonvandenbos authored Apr 28, 2023
2 parents 279f13f + 724fae3 commit 3932f12
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 6 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,21 @@ $model->published_at = Timezone::date($request->published_at);

**This is not a bug**, it is intended behavior since one should be fully aware of the Carbon instance's timezone before assigning it.

### Edge cases

If you need to use the `TimezonedDatetime` or `ImmutableTimezonedDatetime` casts on the default timestamp columns (`created_at` and/or `updated_at`) AND you're expecting to handle dates with timezones other than UTC or the one you've defined with `Timezone::set()`, you will need to apply the `Whitecube\LaravelTimezones\Concerns\HasTimezonedTimestamps` trait on your model.

This is necessary to prevent Laravel's casting of those attributes to occur, which would transform the value in a way where the timezone information is lost, preventing our cast from working properly.

An example of a case where you need to use the trait:

```php
Timezone::set('Europe/Brussels');

$model->created_at = new Carbon('2022-12-15 09:00:00', 'Asia/Taipei');
```


## 🔥 Sponsorships

If you are reliant on this package in your production applications, consider [sponsoring us](https://github.com/sponsors/whitecube)! It is the best way to help us keep doing what we love to do: making great open source software.
Expand Down
29 changes: 23 additions & 6 deletions src/Casts/TimezonedDatetime.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

namespace Whitecube\LaravelTimezones\Casts;

use Illuminate\Support\Facades\Date;
use Whitecube\LaravelTimezones\Facades\Timezone;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Carbon\Carbon;
use DateTime;
use Illuminate\Support\Facades\Config;
use Illuminate\Database\Eloquent\Model;
use Whitecube\LaravelTimezones\DatetimeParser;

class TimezonedDatetime implements CastsAttributes
{
Expand Down Expand Up @@ -79,6 +80,7 @@ public function set($model, $key, $value, $attributes)

/**
* Check if the given key is part of the model's known timestamps
*
* @param Model $model
* @param string $key
* @return bool
Expand All @@ -98,10 +100,25 @@ protected function isTimestamp(Model $model, string $key): bool
*/
public function asDateTime($value, $timezone, $model)
{
return Date::createFromFormat(
$this->format ?? $model->getDateFormat(),
$value,
$timezone,
);
$date = (new DatetimeParser)->parse($value, $this->format ?? $model->getDateFormat());

if ($this->hasTimezone($value)) {
return $date->setTimezone($timezone);
}

return $date->shiftTimezone($timezone);
}

/**
* Check if the provided value contains timezone information
*
* @param mixed $value
* @return bool
*/
protected function hasTimezone(mixed $value): bool
{
return (is_string($value) && array_key_exists('zone', date_parse($value)))
|| (is_a($value, DateTime::class) && $value->getTimezone());
}

}
44 changes: 44 additions & 0 deletions src/Concerns/HasTimezonedTimestamps.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Whitecube\LaravelTimezones\Concerns;

use Whitecube\LaravelTimezones\Casts\TimezonedDatetime;
use Whitecube\LaravelTimezones\Casts\ImmutableTimezonedDatetime;

trait HasTimezonedTimestamps
{
/**
* Determine if the given attribute is a date or date castable.
*
* @param string $key
* @return bool
*/
protected function isDateAttribute($key)
{
return (in_array($key, $this->getDates(), true) ||
$this->isDateCastable($key)) &&
! $this->hasTimezonedDatetimeCast($key);
}

/**
* Check if key is a timezoned datetime cast
*
* @param string $key
* @return bool
*/
protected function hasTimezonedDatetimeCast(string $key): bool
{
$cast = $this->getCasts()[$key] ?? null;

if (! $cast) {
return false;
}

$castClassName = explode(':', $cast)[0];

return in_array(
$castClassName,
[TimezonedDatetime::class, ImmutableTimezonedDatetime::class]
);
}
}
41 changes: 41 additions & 0 deletions src/DatetimeParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Whitecube\LaravelTimezones;

use Illuminate\Database\Eloquent\Concerns\HasAttributes;
use Illuminate\Support\Carbon;

class DatetimeParser
{
use HasAttributes;

/**
* The model's date storage format
*/
protected ?string $format;

/**
* Parse the value into a carbon instance
*
* @param mixed $value
* @param null|string $format
* @return Carbon
*/
public function parse(mixed $value, ?string $format): Carbon
{
$this->format = $format;

return $this->asDateTime($value);
}


/**
* Get the format for database stored dates.
*
* @return string
*/
public function getDateFormat()
{
return $this->format;
}
}
60 changes: 60 additions & 0 deletions tests/CastTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,63 @@
'updated_at' => $date->toJSON()
]);
});

test('a model with a timezone date cast can parse ISO-formatted values properly', function () {
setupFacade();

Config::shouldReceive('get')
->with('app.timezone')
->andReturn('UTC');

$date = new Carbon('2022-12-15 09:00:00', 'UTC');
$model = fakeModelWithCast();

$model->test_at = $date->toIso8601String();
$model->updated_at = $date->toIso8601String();

expect($model->jsonSerialize())
->toBe([
'test_at' => $date->toJSON(),
'updated_at' => $date->toJSON()
]);
});

test('a model with a timezone date cast can parse datetime values properly', function () {
setupFacade();

Config::shouldReceive('get')
->with('app.timezone')
->andReturn('UTC');

$date = new DateTime('2022-12-15 09:00:00');
$model = fakeModelWithCast();

$model->test_at = $date;
$model->updated_at = $date;

expect($model->jsonSerialize())
->toBe([
'test_at' => '2022-12-15T09:00:00.000000Z',
'updated_at' => '2022-12-15T09:00:00.000000Z'
]);
});

test('a model with a timezone date cast can parse datetime values with a defined timezone properly', function () {
setupFacade();

Config::shouldReceive('get')
->with('app.timezone')
->andReturn('UTC');

$date = new DateTime('2022-12-15 09:00:00', new DateTimeZone('Asia/Taipei'));
$model = fakeModelWithCast();

$model->test_at = $date;
$model->updated_at = $date;

expect($model->jsonSerialize())
->toBe([
'test_at' => '2022-12-15T01:00:00.000000Z',
'updated_at' => '2022-12-15T01:00:00.000000Z'
]);
});
3 changes: 3 additions & 0 deletions tests/Pest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use Whitecube\LaravelTimezones\Facades\Timezone as Facade;
use Illuminate\Database\Eloquent\Model;
use Whitecube\LaravelTimezones\Casts\TimezonedDatetime;
use Whitecube\LaravelTimezones\Concerns\HasTimezonedTimestamps;

/*
|--------------------------------------------------------------------------
Expand Down Expand Up @@ -65,6 +66,8 @@ public function getDateFormat()
function fakeModelWithCast()
{
return new class() extends Model {
use HasTimezonedTimestamps;

protected $casts = [
'test_at' => TimezonedDatetime::class,
'created_at' => TimezonedDatetime::class,
Expand Down

0 comments on commit 3932f12

Please sign in to comment.