Skip to content
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

[6.x] Store dates as UTC #11409

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
102942e
Add failing test for the update script
duncanmcclean Jan 28, 2025
616c92d
Implement update script
duncanmcclean Jan 28, 2025
d8452be
Support nested fields in UTC update script
duncanmcclean Jan 30, 2025
bee4d65
Ensure fields inside fieldsets are handled properly
duncanmcclean Jan 30, 2025
2b61ad8
Use data provider in tests
duncanmcclean Jan 30, 2025
e8cb35f
Remove condition I added for troubleshooting
duncanmcclean Jan 30, 2025
d7d2595
Bail out early when the app's timezone is already UTC
duncanmcclean Jan 30, 2025
a92bc0f
Ensure entry date field is updated correctly.
duncanmcclean Jan 30, 2025
cd82107
wip
duncanmcclean Jan 30, 2025
a403b8a
Add test to cover updating users too
duncanmcclean Jan 30, 2025
69d0d39
Add tests for terms & globals too
duncanmcclean Jan 31, 2025
d320852
Ensure entry dates are converted to UTC before saving
duncanmcclean Jan 31, 2025
a133e51
Augment dates using `display_timezone` config
duncanmcclean Jan 31, 2025
0cab171
Ensure `now` cascade variable uses the display timezone
duncanmcclean Jan 31, 2025
8a0ea55
`Entry::date()` handles the conversion, so we don't need to handle it…
duncanmcclean Jan 31, 2025
8d88ddf
Merge remote-tracking branch 'origin/master' into utc-dates
duncanmcclean Feb 6, 2025
baff33d
Ensure GraphQL always returns dates in UTC.
duncanmcclean Feb 6, 2025
f34c61d
Revert "Ensure `now` cascade variable uses the display timezone"
duncanmcclean Feb 11, 2025
74893d6
Revert "Augment dates using `display_timezone` config"
duncanmcclean Feb 11, 2025
db7cc90
UTC forever
duncanmcclean Feb 11, 2025
6343017
Merge remote-tracking branch 'origin/master' into utc-dates
duncanmcclean Feb 11, 2025
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
7 changes: 5 additions & 2 deletions src/Entries/Entry.php
Original file line number Diff line number Diff line change
Expand Up @@ -591,10 +591,13 @@ public function date($date = null)
}

if (strlen($date) === 15) {
return Carbon::createFromFormat('Y-m-d-Hi', $date)->startOfMinute();
return Carbon::createFromFormat('Y-m-d-Hi', $date)
->setTimezone('UTC')
->startOfMinute();
}

return Carbon::createFromFormat('Y-m-d-His', $date);
return Carbon::createFromFormat('Y-m-d-His', $date)
->setTimezone('UTC');
})
->args(func_get_args());
}
Expand Down
2 changes: 1 addition & 1 deletion src/Fieldtypes/Date.php
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ public function toQueryableValue($value)
private function parseSaved($value)
{
try {
return Carbon::createFromFormat($this->saveFormat(), $value);
return Carbon::createFromFormat($this->saveFormat(), $value, 'UTC');
} catch (InvalidFormatException|InvalidArgumentException $e) {
return Carbon::parse($value);
}
Expand Down
1 change: 1 addition & 0 deletions src/Providers/ExtensionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ class ExtensionServiceProvider extends ServiceProvider
Updates\AddSitePermissions::class,
Updates\UseClassBasedStatamicUniqueRules::class,
Updates\MigrateSitesConfigToYaml::class,
Updates\ConvertDatesToUtc::class,
];

public function register()
Expand Down
218 changes: 218 additions & 0 deletions src/UpdateScripts/ConvertDatesToUtc.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php

namespace Statamic\UpdateScripts;

use Carbon\Carbon;
use Statamic\Contracts\Entries\Entry as EntryContract;
use Statamic\Fields\Field;
use Statamic\Fields\Fields;
use Statamic\Fieldtypes\Date as DateFieldtype;
use Statamic\Listeners\Concerns\GetsItemsContainingData;
use Statamic\Support\Arr;

class ConvertDatesToUtc extends UpdateScript
{
use GetsItemsContainingData;

public function shouldUpdate($newVersion, $oldVersion)
{
return $this->isUpdatingTo('6.0.0');
}

public function update()
{
if (config('app.timezone') === 'UTC') {
return;
}

$this
->getItemsContainingData()
->each(function ($item) {
/** @var Fields $fields */
$fields = $item->blueprint()->fields();

$this->recursivelyUpdateFields($item, $fields);

$item->saveQuietly();
});
}

private function recursivelyUpdateFields($item, Fields $fields, ?string $dottedPrefix = null): void
{
$this
->updateDateFields($item, $fields, $dottedPrefix)
->updateDateFieldsInGroups($item, $fields, $dottedPrefix)
->updateDateFieldsInGrids($item, $fields, $dottedPrefix)
->updateDateFieldsInReplicators($item, $fields, $dottedPrefix)
->updateDateFieldsInBard($item, $fields, $dottedPrefix);
}

private function updateDateFields($item, Fields $fields, ?string $dottedPrefix = null): self
{
$fields->all()
->filter(fn (Field $field) => $field->type() === 'date')
->each(function (Field $field) use ($item, $dottedPrefix) {
if (
$item instanceof EntryContract
&& $item->collection()->dated()
&& empty($dottedPrefix)
&& $field->handle() === 'date'
) {
$item->date($item->date());

return;
}

$data = $item->data()->all();

$dottedKey = $dottedPrefix.$field->handle();

if (! Arr::has($data, $dottedKey)) {
return;
}

$value = Arr::get($data, $dottedKey);

$value = $field->get('mode') === 'range'
? $this->processRange($value, $field)
: $this->processSingle($value, $field);

Arr::set($data, $dottedKey, $value);

$item->data($data);
});

return $this;
}

private function updateDateFieldsInGroups($item, Fields $fields, ?string $dottedPrefix = null): self
{
$fields->all()
->filter(fn (Field $field) => $field->type() === 'group')
->each(function (Field $field) use ($item, $dottedPrefix) {
$dottedKey = "{$dottedPrefix}{$field->handle()}";

$this->updateDateFields($item, $field->fieldtype()->fields(), $dottedKey.'.');
});

return $this;
}

private function updateDateFieldsInGrids($item, Fields $fields, ?string $dottedPrefix = null): self
{
$fields->all()
->filter(fn (Field $field) => $field->type() === 'grid')
->each(function (Field $field) use ($item, $dottedPrefix) {
$data = $item->data();
$dottedKey = "{$dottedPrefix}{$field->handle()}";

$rows = Arr::get($data, $dottedKey, []);

collect($rows)->each(function ($set, $setKey) use ($item, $dottedKey, $field) {
$dottedPrefix = "{$dottedKey}.{$setKey}.";
$fields = Arr::get($field->config(), 'fields');

if ($fields) {
$this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix);
}
});
});

return $this;
}

private function updateDateFieldsInReplicators($item, Fields $fields, ?string $dottedPrefix = null): self
{
$fields->all()
->filter(fn (Field $field) => $field->type() === 'replicator')
->each(function (Field $field) use ($item, $dottedPrefix) {
$data = $item->data();
$dottedKey = "{$dottedPrefix}{$field->handle()}";

$sets = Arr::get($data, $dottedKey);

collect($sets)->each(function ($set, $setKey) use ($item, $dottedKey, $field) {
$dottedPrefix = "{$dottedKey}.{$setKey}.";
$setHandle = Arr::get($set, 'type');
$fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields");

if ($setHandle && $fields) {
$this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix);
}
});
});

return $this;
}

private function updateDateFieldsInBard($item, Fields $fields, ?string $dottedPrefix = null): self
{
$fields->all()
->filter(fn (Field $field) => $field->type() === 'bard')
->each(function (Field $field) use ($item, $dottedPrefix) {
$data = $item->data();
$dottedKey = "{$dottedPrefix}{$field->handle()}";

$sets = Arr::get($data, $dottedKey);

collect($sets)->each(function ($set, $setKey) use ($item, $dottedKey, $field) {
$dottedPrefix = "{$dottedKey}.{$setKey}.attrs.values.";
$setHandle = Arr::get($set, 'attrs.values.type');
$fields = Arr::get($field->fieldtype()->flattenedSetsConfig(), "{$setHandle}.fields");

if ($setHandle && $fields) {
$this->recursivelyUpdateFields($item, new Fields($fields), $dottedPrefix);
}
});
});

return $this;
}

private function processRange(array $value, Field $field): array
{
return [
'start' => $this->processSingle($value['start'], $field),
'end' => $this->processSingle($value['end'], $field),
];
}

private function processSingle(int|string $value, Field $field): int|string
{
$value = Carbon::parse($value)
->setTimezone('UTC')
->format($field->get('format', $this->defaultFormat($field)));

if (is_numeric($value)) {
$value = (int) $value;
}

return $value;
}

private function formatForEntryDateField(Field $field): string
{
$format = 'Y-m-d';

if ($field->get('time_enabled')) {
$format .= '-Hi';
}

if ($field->get('time_seconds_enabled')) {
$format .= 's';
}

return $format;
}

private function defaultFormat(Field $field): string
{
if ($field->get('time_enabled') && $field->get('mode', 'single') === 'single') {
return $field->get('time_seconds_enabled')
? DateFieldtype::DEFAULT_DATETIME_WITH_SECONDS_FORMAT
: DateFieldtype::DEFAULT_DATETIME_FORMAT;
}

return DateFieldtype::DEFAULT_DATE_FORMAT;
}
}
22 changes: 22 additions & 0 deletions tests/Data/Entries/EntryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,28 @@ public function setting_date_on_entry_that_doesnt_have_a_collection_set_throws_e
(new Entry)->date('2023-04-19');
}

#[Test]
public function setting_date_on_entry_converts_it_to_utc()
{
config()->set('app.timezone', 'America/New_York'); // -05:00
date_default_timezone_set('America/New_York');

$collection = tap(Collection::make('dated')->dated(true))->save();

$collection->entryBlueprint()->setContents(['fields' => [
['handle' => 'date', 'field' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true]],
]]);

$entry = (new Entry)->collection('dated')->date('2025-01-01');
$this->assertEquals('2025-01-01', $entry->date()->format('Y-m-d'));

$entry = (new Entry)->collection('dated')->date('2025-01-01-1234');
$this->assertEquals('2025-01-01 17:34', $entry->date()->format('Y-m-d H:i'));

$entry = (new Entry)->collection('dated')->date('2025-01-01-123456');
$this->assertEquals('2025-01-01 17:34:56', $entry->date()->format('Y-m-d H:i:s'));
}

#[Test]
public function it_falls_back_to_the_origin_for_the_date()
{
Expand Down
4 changes: 4 additions & 0 deletions tests/Feature/GraphQL/Fieldtypes/DateFieldtypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public function setUp(): void
#[Test]
public function it_gets_dates()
{
// Set the timezone. We want to ensure the date is always returned in UTC.
config()->set('app.timezone', 'America/New_York'); // -05:00
date_default_timezone_set('America/New_York');

// Set the to string format so can see it uses that rather than a coincidence.
// But reset it afterwards.
$originalFormat = Carbon::getToStringFormat();
Expand Down
Loading