Skip to content

Commit 3932f12

Browse files
Merge pull request #5 from whitecube/fix-cast-set-on-timestamps
Fixed cast set on timestamp attributes
2 parents 279f13f + 724fae3 commit 3932f12

File tree

6 files changed

+186
-6
lines changed

6 files changed

+186
-6
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@ $model->published_at = Timezone::date($request->published_at);
147147

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

150+
### Edge cases
151+
152+
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.
153+
154+
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.
155+
156+
An example of a case where you need to use the trait:
157+
158+
```php
159+
Timezone::set('Europe/Brussels');
160+
161+
$model->created_at = new Carbon('2022-12-15 09:00:00', 'Asia/Taipei');
162+
```
163+
164+
150165
## 🔥 Sponsorships
151166

152167
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.

src/Casts/TimezonedDatetime.php

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
namespace Whitecube\LaravelTimezones\Casts;
44

5-
use Illuminate\Support\Facades\Date;
65
use Whitecube\LaravelTimezones\Facades\Timezone;
76
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
87
use Carbon\Carbon;
8+
use DateTime;
99
use Illuminate\Support\Facades\Config;
1010
use Illuminate\Database\Eloquent\Model;
11+
use Whitecube\LaravelTimezones\DatetimeParser;
1112

1213
class TimezonedDatetime implements CastsAttributes
1314
{
@@ -79,6 +80,7 @@ public function set($model, $key, $value, $attributes)
7980

8081
/**
8182
* Check if the given key is part of the model's known timestamps
83+
*
8284
* @param Model $model
8385
* @param string $key
8486
* @return bool
@@ -98,10 +100,25 @@ protected function isTimestamp(Model $model, string $key): bool
98100
*/
99101
public function asDateTime($value, $timezone, $model)
100102
{
101-
return Date::createFromFormat(
102-
$this->format ?? $model->getDateFormat(),
103-
$value,
104-
$timezone,
105-
);
103+
$date = (new DatetimeParser)->parse($value, $this->format ?? $model->getDateFormat());
104+
105+
if ($this->hasTimezone($value)) {
106+
return $date->setTimezone($timezone);
107+
}
108+
109+
return $date->shiftTimezone($timezone);
110+
}
111+
112+
/**
113+
* Check if the provided value contains timezone information
114+
*
115+
* @param mixed $value
116+
* @return bool
117+
*/
118+
protected function hasTimezone(mixed $value): bool
119+
{
120+
return (is_string($value) && array_key_exists('zone', date_parse($value)))
121+
|| (is_a($value, DateTime::class) && $value->getTimezone());
106122
}
123+
107124
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace Whitecube\LaravelTimezones\Concerns;
4+
5+
use Whitecube\LaravelTimezones\Casts\TimezonedDatetime;
6+
use Whitecube\LaravelTimezones\Casts\ImmutableTimezonedDatetime;
7+
8+
trait HasTimezonedTimestamps
9+
{
10+
/**
11+
* Determine if the given attribute is a date or date castable.
12+
*
13+
* @param string $key
14+
* @return bool
15+
*/
16+
protected function isDateAttribute($key)
17+
{
18+
return (in_array($key, $this->getDates(), true) ||
19+
$this->isDateCastable($key)) &&
20+
! $this->hasTimezonedDatetimeCast($key);
21+
}
22+
23+
/**
24+
* Check if key is a timezoned datetime cast
25+
*
26+
* @param string $key
27+
* @return bool
28+
*/
29+
protected function hasTimezonedDatetimeCast(string $key): bool
30+
{
31+
$cast = $this->getCasts()[$key] ?? null;
32+
33+
if (! $cast) {
34+
return false;
35+
}
36+
37+
$castClassName = explode(':', $cast)[0];
38+
39+
return in_array(
40+
$castClassName,
41+
[TimezonedDatetime::class, ImmutableTimezonedDatetime::class]
42+
);
43+
}
44+
}

src/DatetimeParser.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Whitecube\LaravelTimezones;
4+
5+
use Illuminate\Database\Eloquent\Concerns\HasAttributes;
6+
use Illuminate\Support\Carbon;
7+
8+
class DatetimeParser
9+
{
10+
use HasAttributes;
11+
12+
/**
13+
* The model's date storage format
14+
*/
15+
protected ?string $format;
16+
17+
/**
18+
* Parse the value into a carbon instance
19+
*
20+
* @param mixed $value
21+
* @param null|string $format
22+
* @return Carbon
23+
*/
24+
public function parse(mixed $value, ?string $format): Carbon
25+
{
26+
$this->format = $format;
27+
28+
return $this->asDateTime($value);
29+
}
30+
31+
32+
/**
33+
* Get the format for database stored dates.
34+
*
35+
* @return string
36+
*/
37+
public function getDateFormat()
38+
{
39+
return $this->format;
40+
}
41+
}

tests/CastTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,63 @@
161161
'updated_at' => $date->toJSON()
162162
]);
163163
});
164+
165+
test('a model with a timezone date cast can parse ISO-formatted values properly', function () {
166+
setupFacade();
167+
168+
Config::shouldReceive('get')
169+
->with('app.timezone')
170+
->andReturn('UTC');
171+
172+
$date = new Carbon('2022-12-15 09:00:00', 'UTC');
173+
$model = fakeModelWithCast();
174+
175+
$model->test_at = $date->toIso8601String();
176+
$model->updated_at = $date->toIso8601String();
177+
178+
expect($model->jsonSerialize())
179+
->toBe([
180+
'test_at' => $date->toJSON(),
181+
'updated_at' => $date->toJSON()
182+
]);
183+
});
184+
185+
test('a model with a timezone date cast can parse datetime values properly', function () {
186+
setupFacade();
187+
188+
Config::shouldReceive('get')
189+
->with('app.timezone')
190+
->andReturn('UTC');
191+
192+
$date = new DateTime('2022-12-15 09:00:00');
193+
$model = fakeModelWithCast();
194+
195+
$model->test_at = $date;
196+
$model->updated_at = $date;
197+
198+
expect($model->jsonSerialize())
199+
->toBe([
200+
'test_at' => '2022-12-15T09:00:00.000000Z',
201+
'updated_at' => '2022-12-15T09:00:00.000000Z'
202+
]);
203+
});
204+
205+
test('a model with a timezone date cast can parse datetime values with a defined timezone properly', function () {
206+
setupFacade();
207+
208+
Config::shouldReceive('get')
209+
->with('app.timezone')
210+
->andReturn('UTC');
211+
212+
$date = new DateTime('2022-12-15 09:00:00', new DateTimeZone('Asia/Taipei'));
213+
$model = fakeModelWithCast();
214+
215+
$model->test_at = $date;
216+
$model->updated_at = $date;
217+
218+
expect($model->jsonSerialize())
219+
->toBe([
220+
'test_at' => '2022-12-15T01:00:00.000000Z',
221+
'updated_at' => '2022-12-15T01:00:00.000000Z'
222+
]);
223+
});

tests/Pest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Whitecube\LaravelTimezones\Facades\Timezone as Facade;
55
use Illuminate\Database\Eloquent\Model;
66
use Whitecube\LaravelTimezones\Casts\TimezonedDatetime;
7+
use Whitecube\LaravelTimezones\Concerns\HasTimezonedTimestamps;
78

89
/*
910
|--------------------------------------------------------------------------
@@ -65,6 +66,8 @@ public function getDateFormat()
6566
function fakeModelWithCast()
6667
{
6768
return new class() extends Model {
69+
use HasTimezonedTimestamps;
70+
6871
protected $casts = [
6972
'test_at' => TimezonedDatetime::class,
7073
'created_at' => TimezonedDatetime::class,

0 commit comments

Comments
 (0)