diff --git a/core/modules/datetime/datetime.views.inc b/core/modules/datetime/datetime.views.inc index d3b0d18617d04b7343a9c668517e8ae41263bd6f..93d6cd4d30382d8386bf031180d470cb3a1784f1 100644 --- a/core/modules/datetime/datetime.views.inc +++ b/core/modules/datetime/datetime.views.inc @@ -39,6 +39,8 @@ function datetime_field_views_data(FieldStorageConfigInterface $field_storage) { 'argument' => [ 'field' => $field_storage->getName() . '_value', 'id' => 'datetime_' . $argument_type, + 'entity_type' => $field_storage->getTargetEntityTypeId(), + 'field_name' => $field_storage->getName(), ], 'group' => $group, ]; diff --git a/core/modules/datetime/src/Plugin/views/argument/Date.php b/core/modules/datetime/src/Plugin/views/argument/Date.php index 3e7d461adff55a4b02b9548eede162ee66f1589e..4b8caa57c6aa1747f3c175506b7b09c519abd464 100644 --- a/core/modules/datetime/src/Plugin/views/argument/Date.php +++ b/core/modules/datetime/src/Plugin/views/argument/Date.php @@ -2,6 +2,9 @@ namespace Drupal\datetime\Plugin\views\argument; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\views\FieldAPIHandlerTrait; use Drupal\views\Plugin\views\argument\Date as NumericDate; /** @@ -22,12 +25,36 @@ */ class Date extends NumericDate { + use FieldAPIHandlerTrait; + + /** + * Determines if the timezone offset is calculated. + * + * @var bool + */ + protected $calculateOffset = TRUE; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteMatchInterface $route_match) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $route_match); + + $definition = $this->getFieldStorageDefinition(); + if ($definition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { + // Timezone offset calculation is not applicable to dates that are stored + // as date-only. + $this->calculateOffset = FALSE; + } + } + /** * {@inheritdoc} */ public function getDateField() { - // Return the real field, since it is already in string format. - return "$this->tableAlias.$this->realField"; + // Use string date storage/formatting since datetime fields are stored as + // strings rather than UNIX timestamps. + return $this->query->getDateField("$this->tableAlias.$this->realField", TRUE, $this->calculateOffset); } /** diff --git a/core/modules/datetime/src/Plugin/views/filter/Date.php b/core/modules/datetime/src/Plugin/views/filter/Date.php index 378f33fe84c80366cfe18f3faf4f5d8dcfb6dbb7..14215206c58ea2739a77ede2ee3869ae643cec87 100644 --- a/core/modules/datetime/src/Plugin/views/filter/Date.php +++ b/core/modules/datetime/src/Plugin/views/filter/Date.php @@ -2,6 +2,7 @@ namespace Drupal\datetime\Plugin\views\filter; +use Drupal\Component\Datetime\DateTimePlus; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; @@ -41,6 +42,13 @@ class Date extends NumericDate implements ContainerFactoryPluginInterface { */ protected $dateFormat = DateTimeItemInterface::DATETIME_STORAGE_FORMAT; + /** + * Determines if the timezone offset is calculated. + * + * @var bool + */ + protected $calculateOffset = TRUE; + /** * The request stack used to determin current time. * @@ -67,10 +75,13 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition $this->dateFormatter = $date_formatter; $this->requestStack = $request_stack; - // Date format depends on field storage format. $definition = $this->getFieldStorageDefinition(); if ($definition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { + // Date format depends on field storage format. $this->dateFormat = DateTimeItemInterface::DATE_STORAGE_FORMAT; + // Timezone offset calculation is not applicable to dates that are stored + // as date-only. + $this->calculateOffset = FALSE; } } @@ -91,20 +102,23 @@ public static function create(ContainerInterface $container, array $configuratio * Override parent method, which deals with dates as integers. */ protected function opBetween($field) { - $origin = ($this->value['type'] == 'offset') ? $this->requestStack->getCurrentRequest()->server->get('REQUEST_TIME') : 0; - $a = intval(strtotime($this->value['min'], $origin)); - $b = intval(strtotime($this->value['max'], $origin)); + $timezone = $this->getTimezone(); + $origin_offset = $this->getOffset($this->value['min'], $timezone); - // Formatting will vary on date storage. + // Although both 'min' and 'max' values are required, default empty 'min' + // value as UNIX timestamp 0. + $min = (!empty($this->value['min'])) ? $this->value['min'] : '@0'; // Convert to ISO format and format for query. UTC timezone is used since // dates are stored in UTC. - $a = $this->query->getDateFormat("'" . $this->dateFormatter->format($a, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", $this->dateFormat, TRUE); - $b = $this->query->getDateFormat("'" . $this->dateFormatter->format($b, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", $this->dateFormat, TRUE); + $a = new DateTimePlus($min, new \DateTimeZone($timezone)); + $a = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($a->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); + $b = new DateTimePlus($this->value['max'], new \DateTimeZone($timezone)); + $b = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($b->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); // This is safe because we are manually scrubbing the values. $operator = strtoupper($this->operator); - $field = $this->query->getDateFormat($field, $this->dateFormat, TRUE); + $field = $this->query->getDateFormat($this->query->getDateField($field, TRUE, $this->calculateOffset), $this->dateFormat, TRUE); $this->query->addWhereExpression($this->options['group'], "$field $operator $a AND $b"); } @@ -112,15 +126,57 @@ protected function opBetween($field) { * Override parent method, which deals with dates as integers. */ protected function opSimple($field) { - $origin = (!empty($this->value['type']) && $this->value['type'] == 'offset') ? $this->requestStack->getCurrentRequest()->server->get('REQUEST_TIME') : 0; - $value = intval(strtotime($this->value['value'], $origin)); + $timezone = $this->getTimezone(); + $origin_offset = $this->getOffset($this->value['value'], $timezone); - // Convert to ISO. UTC is used since dates are stored in UTC. - $value = $this->query->getDateFormat("'" . $this->dateFormatter->format($value, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", $this->dateFormat, TRUE); + // Convert to ISO. UTC timezone is used since dates are stored in UTC. + $value = new DateTimePlus($this->value['value'], new \DateTimeZone($timezone)); + $value = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($value->getTimestamp() + $origin_offset, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $this->calculateOffset), $this->dateFormat, TRUE); // This is safe because we are manually scrubbing the value. - $field = $this->query->getDateFormat($field, $this->dateFormat, TRUE); + $field = $this->query->getDateFormat($this->query->getDateField($field, TRUE, $this->calculateOffset), $this->dateFormat, TRUE); $this->query->addWhereExpression($this->options['group'], "$field $this->operator $value"); } + /** + * Get the proper time zone to use in computations. + * + * Date-only fields do not have a time zone associated with them, so the + * filter input needs to use UTC for reference. Otherwise, use the time zone + * for the current user. + * + * @return string + * The time zone name. + */ + protected function getTimezone() { + return $this->dateFormat === DateTimeItemInterface::DATE_STORAGE_FORMAT + ? DateTimeItemInterface::STORAGE_TIMEZONE + : drupal_get_user_timezone(); + } + + /** + * Get the proper offset from UTC to use in computations. + * + * @param string $time + * A date/time string compatible with \DateTime. It is used as the + * reference for computing the offset, which can vary based on the time + * zone rules. + * @param string $timezone + * The time zone that $time is in. + * + * @return int + * The computed offset in seconds. + */ + protected function getOffset($time, $timezone) { + // Date-only fields do not have a time zone or offset from UTC associated + // with them. For relative (i.e. 'offset') comparisons, we need to compute + // the user's offset from UTC for use in the query. + $origin_offset = 0; + if ($this->dateFormat === DateTimeItemInterface::DATE_STORAGE_FORMAT && $this->value['type'] === 'offset') { + $origin_offset = $origin_offset + timezone_offset_get(new \DateTimeZone(drupal_get_user_timezone()), new \DateTime($time, new \DateTimeZone($timezone))); + } + + return $origin_offset; + } + } diff --git a/core/modules/datetime/src/Plugin/views/sort/Date.php b/core/modules/datetime/src/Plugin/views/sort/Date.php index 2c8338ad259945bc72f94278c3b2669a9995ca26..0049e867feb7a1681e716e8dc319cd00f4132805 100644 --- a/core/modules/datetime/src/Plugin/views/sort/Date.php +++ b/core/modules/datetime/src/Plugin/views/sort/Date.php @@ -2,6 +2,8 @@ namespace Drupal\datetime\Plugin\views\sort; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\views\FieldAPIHandlerTrait; use Drupal\views\Plugin\views\sort\Date as NumericDate; /** @@ -14,12 +16,38 @@ */ class Date extends NumericDate { + use FieldAPIHandlerTrait; + /** + * Determines if the timezone offset is calculated. + * + * @var bool + */ + protected $calculateOffset = TRUE; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $definition = $this->getFieldStorageDefinition(); + if ($definition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATE) { + // Timezone offset calculation is not applicable to dates that are stored + // as date-only. + $this->calculateOffset = FALSE; + } + } + + /** + * {@inheritdoc} + * * Override to account for dates stored as strings. */ public function getDateField() { - // Return the real field, since it is already in string format. - return "$this->tableAlias.$this->realField"; + // Use string date storage/formatting since datetime fields are stored as + // strings rather than UNIX timestamps. + return $this->query->getDateField("$this->tableAlias.$this->realField", TRUE, $this->calculateOffset); } /** diff --git a/core/modules/datetime/tests/src/Kernel/Views/ArgumentDateTimeTest.php b/core/modules/datetime/tests/src/Kernel/Views/ArgumentDateTimeTest.php index 614548395876a8b1df5b6b275c7bcacaa46e740e..8261b27793f8555c3069110cf681c3bbf4426ca0 100644 --- a/core/modules/datetime/tests/src/Kernel/Views/ArgumentDateTimeTest.php +++ b/core/modules/datetime/tests/src/Kernel/Views/ArgumentDateTimeTest.php @@ -28,6 +28,9 @@ protected function setUp($import_test_views = TRUE) { '2000-10-10', '2001-10-10', '2002-01-01', + // Add a date that is the year 2002 in UTC, but 2003 in the site's time + // zone (Australia/Sydney). + '2002-12-31T23:00:00', ]; foreach ($dates as $date) { $node = Node::create([ @@ -64,6 +67,25 @@ public function testDatetimeArgumentYear() { $expected[] = ['nid' => $this->nodes[2]->id()]; $this->assertIdenticalResultset($view, $expected, $this->map); $view->destroy(); + + $view->setDisplay('default'); + $this->executeView($view, ['2003']); + $expected = []; + $expected[] = ['nid' => $this->nodes[3]->id()]; + $this->assertIdenticalResultset($view, $expected, $this->map); + $view->destroy(); + + // Tests different system timezone with the same nodes. + $this->setSiteTimezone('America/Vancouver'); + + $view->setDisplay('default'); + $this->executeView($view, ['2002']); + $expected = []; + // Only the 3rd node is returned here since UTC 2002-01-01T00:00:00 is still + // in 2001 for this user timezone. + $expected[] = ['nid' => $this->nodes[3]->id()]; + $this->assertIdenticalResultset($view, $expected, $this->map); + $view->destroy(); } /** @@ -87,6 +109,7 @@ public function testDatetimeArgumentMonth() { $this->executeView($view, ['01']); $expected = []; $expected[] = ['nid' => $this->nodes[2]->id()]; + $expected[] = ['nid' => $this->nodes[3]->id()]; $this->assertIdenticalResultset($view, $expected, $this->map); $view->destroy(); } @@ -112,6 +135,7 @@ public function testDatetimeArgumentDay() { $this->executeView($view, ['01']); $expected = []; $expected[] = ['nid' => $this->nodes[2]->id()]; + $expected[] = ['nid' => $this->nodes[3]->id()]; $this->assertIdenticalResultset($view, $expected, $this->map); $view->destroy(); } @@ -157,6 +181,7 @@ public function testDatetimeArgumentWeek() { $this->executeView($view, ['01']); $expected = []; $expected[] = ['nid' => $this->nodes[2]->id()]; + $expected[] = ['nid' => $this->nodes[3]->id()]; $this->assertIdenticalResultset($view, $expected, $this->map); $view->destroy(); } diff --git a/core/modules/datetime/tests/src/Kernel/Views/DateTimeHandlerTestBase.php b/core/modules/datetime/tests/src/Kernel/Views/DateTimeHandlerTestBase.php index f0c9c786144adbd3ccf15f70df575aed2a78efeb..20a3542319f8919da8ffe173aa8f856d86bf0c9d 100644 --- a/core/modules/datetime/tests/src/Kernel/Views/DateTimeHandlerTestBase.php +++ b/core/modules/datetime/tests/src/Kernel/Views/DateTimeHandlerTestBase.php @@ -41,6 +41,7 @@ abstract class DateTimeHandlerTestBase extends ViewsKernelTestBase { protected function setUp($import_test_views = TRUE) { parent::setUp($import_test_views); + $this->installSchema('node', 'node_access'); $this->installEntitySchema('node'); $this->installEntitySchema('user'); @@ -76,4 +77,18 @@ protected function setUp($import_test_views = TRUE) { ViewTestData::createTestViews(get_class($this), ['datetime_test']); } + /** + * Sets the site timezone to a given timezone. + * + * @param string $timezone + * The timezone identifier to set. + */ + protected function setSiteTimezone($timezone) { + // Set an explicit site timezone, and disallow per-user timezones. + $this->config('system.date') + ->set('timezone.user.configurable', 0) + ->set('timezone.default', $timezone) + ->save(); + } + } diff --git a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php index 23f40948e868c250b218fef2a1670458d13c9a1f..f4a6342956a7cf2fe0a45ca6363f5e5cdd3f8aaa 100644 --- a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php +++ b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php @@ -2,8 +2,8 @@ namespace Drupal\Tests\datetime\Kernel\Views; +use Drupal\Component\Datetime\DateTimePlus; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; -use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Drupal\field\Entity\FieldStorageConfig; use Drupal\node\Entity\Node; use Drupal\views\Views; @@ -21,9 +21,26 @@ class FilterDateTest extends DateTimeHandlerTestBase { public static $testViews = ['test_filter_datetime']; /** - * For offset tests, set to the current time. + * An array of timezone extremes to test. + * + * @var string[] */ - protected static $date; + protected static $timezones = [ + // UTC-12, no DST. + 'Pacific/Kwajalein', + // UTC-11, no DST. + 'Pacific/Midway', + // UTC-7, no DST. + 'America/Phoenix', + // UTC. + 'UTC', + // UTC+5:30, no DST. + 'Asia/Kolkata', + // UTC+12, no DST. + 'Pacific/Funafuti', + // UTC+13, no DST. + 'Pacific/Tongatapu', + ]; /** * {@inheritdoc} @@ -33,30 +50,24 @@ class FilterDateTest extends DateTimeHandlerTestBase { protected function setUp($import_test_views = TRUE) { parent::setUp($import_test_views); - // Set to 'today'. - static::$date = REQUEST_TIME; - // Change field storage to date-only. $storage = FieldStorageConfig::load('node.' . static::$field_name); $storage->setSetting('datetime_type', DateTimeItem::DATETIME_TYPE_DATE); $storage->save(); - $dates = [ - // Tomorrow. - \Drupal::service('date.formatter')->format(static::$date + 86400, 'custom', DateTimeItemInterface::DATE_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE), - // Today. - \Drupal::service('date.formatter')->format(static::$date, 'custom', DateTimeItemInterface::DATE_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE), - // Yesterday. - \Drupal::service('date.formatter')->format(static::$date - 86400, 'custom', DateTimeItemInterface::DATE_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE), - ]; + // Retrieve tomorrow, today and yesterday dates just to create the nodes. + $timestamp = $this->getUTCEquivalentOfUserNowAsTimestamp(); + $dates = $this->getRelativeDateValuesFromTimestamp($timestamp); + // Clean the nodes on setUp. + $this->nodes = []; foreach ($dates as $date) { $node = Node::create([ 'title' => $this->randomMachineName(8), 'type' => 'page', 'field_date' => [ 'value' => $date, - ] + ], ]); $node->save(); $this->nodes[] = $node; @@ -70,48 +81,156 @@ public function testDateOffsets() { $view = Views::getView('test_filter_datetime'); $field = static::$field_name . '_value'; - // Test simple operations. - $view->initHandlers(); - - // A greater than or equal to 'now', should return the 'today' and - // the 'tomorrow' node. - $view->filter[$field]->operator = '>='; - $view->filter[$field]->value['type'] = 'offset'; - $view->filter[$field]->value['value'] = 'now'; - $view->setDisplay('default'); - $this->executeView($view); - $expected_result = [ - ['nid' => $this->nodes[0]->id()], - ['nid' => $this->nodes[1]->id()], - ]; - $this->assertIdenticalResultset($view, $expected_result, $this->map); - $view->destroy(); - - // Only dates in the past. - $view->initHandlers(); - $view->filter[$field]->operator = '<'; - $view->filter[$field]->value['type'] = 'offset'; - $view->filter[$field]->value['value'] = 'now'; - $view->setDisplay('default'); - $this->executeView($view); - $expected_result = [ - ['nid' => $this->nodes[2]->id()], - ]; - $this->assertIdenticalResultset($view, $expected_result, $this->map); - $view->destroy(); - - // Test offset for between operator. Only the 'tomorrow' node should appear. - $view->initHandlers(); - $view->filter[$field]->operator = 'between'; - $view->filter[$field]->value['type'] = 'offset'; - $view->filter[$field]->value['max'] = '+2 days'; - $view->filter[$field]->value['min'] = '+1 day'; - $view->setDisplay('default'); - $this->executeView($view); - $expected_result = [ - ['nid' => $this->nodes[0]->id()], + foreach (static::$timezones as $timezone) { + + $this->setSiteTimezone($timezone); + $timestamp = $this->getUTCEquivalentOfUserNowAsTimestamp(); + $dates = $this->getRelativeDateValuesFromTimestamp($timestamp); + $this->updateNodesDateFieldsValues($dates); + + // Test simple operations. + $view->initHandlers(); + + // A greater than or equal to 'now', should return the 'today' and the + // 'tomorrow' node. + $view->filter[$field]->operator = '>='; + $view->filter[$field]->value['type'] = 'offset'; + $view->filter[$field]->value['value'] = 'now'; + $view->setDisplay('default'); + $this->executeView($view); + $expected_result = [ + ['nid' => $this->nodes[0]->id()], + ['nid' => $this->nodes[1]->id()], + ]; + $this->assertIdenticalResultset($view, $expected_result, $this->map); + $view->destroy(); + + // Only dates in the past. + $view->initHandlers(); + $view->filter[$field]->operator = '<'; + $view->filter[$field]->value['type'] = 'offset'; + $view->filter[$field]->value['value'] = 'now'; + $view->setDisplay('default'); + $this->executeView($view); + $expected_result = [ + ['nid' => $this->nodes[2]->id()], + ]; + $this->assertIdenticalResultset($view, $expected_result, $this->map); + $view->destroy(); + + // Test offset for between operator. Only 'tomorrow' node should appear. + $view->initHandlers(); + $view->filter[$field]->operator = 'between'; + $view->filter[$field]->value['type'] = 'offset'; + $view->filter[$field]->value['max'] = '+2 days'; + $view->filter[$field]->value['min'] = '+1 day'; + $view->setDisplay('default'); + $this->executeView($view); + $expected_result = [ + ['nid' => $this->nodes[0]->id()], + ]; + $this->assertIdenticalResultset($view, $expected_result, $this->map); + $view->destroy(); + } + } + + /** + * Test date filter with date-only fields. + */ + public function testDateIs() { + $view = Views::getView('test_filter_datetime'); + $field = static::$field_name . '_value'; + + foreach (static::$timezones as $timezone) { + + $this->setSiteTimezone($timezone); + $timestamp = $this->getUTCEquivalentOfUserNowAsTimestamp(); + $dates = $this->getRelativeDateValuesFromTimestamp($timestamp); + $this->updateNodesDateFieldsValues($dates); + + // Test simple operations. + $view->initHandlers(); + + // Filtering with nodes date-only values (format: Y-m-d) to test UTC + // conversion does NOT change the day. + $view->filter[$field]->operator = '='; + $view->filter[$field]->value['type'] = 'date'; + $view->filter[$field]->value['value'] = $this->nodes[2]->field_date->first()->getValue()['value']; + $view->setDisplay('default'); + $this->executeView($view); + $expected_result = [ + ['nid' => $this->nodes[2]->id()], + ]; + $this->assertIdenticalResultset($view, $expected_result, $this->map); + $view->destroy(); + + // Test offset for between operator. Only 'today' and 'tomorrow' nodes + // should appear. + $view->initHandlers(); + $view->filter[$field]->operator = 'between'; + $view->filter[$field]->value['type'] = 'date'; + $view->filter[$field]->value['max'] = $this->nodes[0]->field_date->first()->getValue()['value']; + $view->filter[$field]->value['min'] = $this->nodes[1]->field_date->first()->getValue()['value']; + $view->setDisplay('default'); + $this->executeView($view); + $expected_result = [ + ['nid' => $this->nodes[0]->id()], + ['nid' => $this->nodes[1]->id()], + ]; + $this->assertIdenticalResultset($view, $expected_result, $this->map); + $view->destroy(); + } + } + + /** + * Returns UTC timestamp of user's TZ 'now'. + * + * The date field stores date_only values without conversion, considering them + * already as UTC. This method returns the UTC equivalent of user's 'now' as a + * unix timestamp, so they match using Y-m-d format. + * + * @return int + * Unix timestamp. + */ + protected function getUTCEquivalentOfUserNowAsTimestamp() { + $user_now = new DateTimePlus('now', new \DateTimeZone(drupal_get_user_timezone())); + $utc_equivalent = new DateTimePlus($user_now->format('Y-m-d H:i:s'), new \DateTimeZone(DATETIME_STORAGE_TIMEZONE)); + + return $utc_equivalent->getTimestamp(); + } + + /** + * Returns an array formatted date_only values. + * + * @param int $timestamp + * Unix Timestamp equivalent to user's "now". + * + * @return array + * An array of DATETIME_DATE_STORAGE_FORMAT date values. In order tomorrow, + * today and yesterday. + */ + protected function getRelativeDateValuesFromTimestamp($timestamp) { + return [ + // Tomorrow. + \Drupal::service('date.formatter')->format($timestamp + 86400, 'custom', DATETIME_DATE_STORAGE_FORMAT, DATETIME_STORAGE_TIMEZONE), + // Today. + \Drupal::service('date.formatter')->format($timestamp, 'custom', DATETIME_DATE_STORAGE_FORMAT, DATETIME_STORAGE_TIMEZONE), + // Yesterday. + \Drupal::service('date.formatter')->format($timestamp - 86400, 'custom', DATETIME_DATE_STORAGE_FORMAT, DATETIME_STORAGE_TIMEZONE), ]; - $this->assertIdenticalResultset($view, $expected_result, $this->map); + } + + /** + * Updates tests nodes date fields values. + * + * @param array $dates + * An array of DATETIME_DATE_STORAGE_FORMAT date values. + */ + protected function updateNodesDateFieldsValues(array $dates) { + foreach ($dates as $index => $date) { + $this->nodes[$index]->{static::$field_name}->value = $date; + $this->nodes[$index]->save(); + } } } diff --git a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php index 5282da33ef7790ddf10dc6d0d5ba20009f98032f..7cb0fa040ab8c04651129e2c8e08a753febc84cf 100644 --- a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php +++ b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php @@ -40,6 +40,9 @@ protected function setUp($import_test_views = TRUE) { // Set the timezone. date_default_timezone_set(static::$timezone); + $this->config('system.date') + ->set('timezone.default', static::$timezone) + ->save(); // Add some basic test nodes. $dates = [ diff --git a/core/modules/views/src/Plugin/views/query/DateSqlInterface.php b/core/modules/views/src/Plugin/views/query/DateSqlInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..caa741939b300724e71af9a7778a818d931d426d --- /dev/null +++ b/core/modules/views/src/Plugin/views/query/DateSqlInterface.php @@ -0,0 +1,62 @@ +<?php + +namespace Drupal\views\Plugin\views\query; + +/** + * Defines an interface for handling date queries with SQL. + * + * @internal + * Classes implementing this interface should only be used by the Views SQL + * query plugin. + * + * @see \Drupal\views\Plugin\views\query\Sql + */ +interface DateSqlInterface { + + /** + * Returns a native database expression for a given field. + * + * @param string $field + * The query field that will be used in the expression. + * @param bool $string_date + * For certain databases, date format functions vary depending on string or + * numeric storage. + * + * @return string + * An expression representing a date field with timezone. + */ + public function getDateField($field, $string_date); + + /** + * Creates a native database date formatting. + * + * @param string $field + * An appropriate query expression pointing to the date field. + * @param string $format + * A format string for the result. For example: 'Y-m-d H:i:s'. + * + * @return string + * A string representing the field formatted as a date as specified by + * $format. + */ + public function getDateFormat($field, $format); + + /** + * Applies the given offset to the given field. + * + * @param string &$field + * The date field in a string format. + * @param int $offset + * The timezone offset in seconds. + */ + public function setFieldTimezoneOffset(&$field, $offset); + + /** + * Set the database to the given timezone. + * + * @param string $offset + * The timezone. + */ + public function setTimezoneOffset($offset); + +} diff --git a/core/modules/views/src/Plugin/views/query/MysqlDateSql.php b/core/modules/views/src/Plugin/views/query/MysqlDateSql.php new file mode 100644 index 0000000000000000000000000000000000000000..00ad873b83a8e076f56c1becc060c9cce61866ab --- /dev/null +++ b/core/modules/views/src/Plugin/views/query/MysqlDateSql.php @@ -0,0 +1,93 @@ +<?php + +namespace Drupal\views\Plugin\views\query; + +use Drupal\Core\Database\Connection; + +/** + * MySQL-specific date handling. + * + * @internal + * This class should only be used by the Views SQL query plugin. + * + * @see \Drupal\views\Plugin\views\query\Sql + */ +class MysqlDateSql implements DateSqlInterface { + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * An array of PHP-to-MySQL replacement patterns. + */ + protected static $replace = [ + 'Y' => '%Y', + 'y' => '%y', + 'M' => '%b', + 'm' => '%m', + 'n' => '%c', + 'F' => '%M', + 'D' => '%a', + 'd' => '%d', + 'l' => '%W', + 'j' => '%e', + 'W' => '%v', + 'H' => '%H', + 'h' => '%h', + 'i' => '%i', + 's' => '%s', + 'A' => '%p', + ]; + + /** + * Constructs the MySQL-specific date sql class. + * + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + public function __construct(Connection $database) { + $this->database = $database; + } + + /** + * {@inheritdoc} + */ + public function getDateField($field, $string_date) { + if ($string_date) { + return $field; + } + + // Base date field storage is timestamp, so the date to be returned here is + // epoch + stored value (seconds from epoch). + return "DATE_ADD('19700101', INTERVAL $field SECOND)"; + } + + /** + * {@inheritdoc} + */ + public function getDateFormat($field, $format) { + $format = strtr($format, static::$replace); + return "DATE_FORMAT($field, '$format')"; + } + + /** + * {@inheritdoc} + */ + public function setTimezoneOffset($offset) { + $this->database->query("SET @@session.time_zone = '$offset'"); + } + + /** + * {@inheritdoc} + */ + public function setFieldTimezoneOffset(&$field, $offset) { + if (!empty($offset)) { + $field = "($field + INTERVAL $offset SECOND)"; + } + } + +} diff --git a/core/modules/views/src/Plugin/views/query/PostgresqlDateSql.php b/core/modules/views/src/Plugin/views/query/PostgresqlDateSql.php new file mode 100644 index 0000000000000000000000000000000000000000..c03c416456a88a4d0eb8875c9d815ff105efef66 --- /dev/null +++ b/core/modules/views/src/Plugin/views/query/PostgresqlDateSql.php @@ -0,0 +1,93 @@ +<?php + +namespace Drupal\views\Plugin\views\query; + +use Drupal\Core\Database\Connection; + +/** + * PostgreSQL-specific date handling. + * + * @internal + * This class should only be used by the Views SQL query plugin. + * + * @see \Drupal\views\Plugin\views\query\Sql + */ +class PostgresqlDateSql implements DateSqlInterface { + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * An array of PHP-to-PostgreSQL replacement patterns. + * + * @var array + */ + protected static $replace = [ + 'Y' => 'YYYY', + 'y' => 'YY', + 'M' => 'Mon', + 'm' => 'MM', + // No format for Numeric representation of a month, without leading zeros. + 'n' => 'MM', + 'F' => 'Month', + 'D' => 'Dy', + 'd' => 'DD', + 'l' => 'Day', + // No format for Day of the month without leading zeros. + 'j' => 'DD', + 'W' => 'IW', + 'H' => 'HH24', + 'h' => 'HH12', + 'i' => 'MI', + 's' => 'SS', + 'A' => 'AM', + ]; + + /** + * Constructs the PostgreSQL-specific date sql class. + * + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + public function __construct(Connection $database) { + $this->database = $database; + } + + /** + * {@inheritdoc} + */ + public function getDateField($field, $string_date) { + if ($string_date) { + // Ensures compatibility with field offset operation below. + return "TO_TIMESTAMP($field, 'YYYY-MM-DD HH24:MI:SS')"; + } + return "TO_TIMESTAMP($field)"; + } + + /** + * {@inheritdoc} + */ + public function getDateFormat($field, $format) { + $format = strtr($format, static::$replace); + return "TO_CHAR($field, '$format')"; + } + + /** + * {@inheritdoc} + */ + public function setFieldTimezoneOffset(&$field, $offset) { + $field = "($field + INTERVAL '$offset SECONDS')"; + } + + /** + * {@inheritdoc} + */ + public function setTimezoneOffset($offset) { + $this->database->query("SET TIME ZONE INTERVAL '$offset' HOUR TO MINUTE"); + } + +} diff --git a/core/modules/views/src/Plugin/views/query/QueryPluginBase.php b/core/modules/views/src/Plugin/views/query/QueryPluginBase.php index 35453c1172dd991b4ce5d15386e96ef668519ac2..83e0bcfa2403e04581f0937da4f9e462a0df7f7c 100644 --- a/core/modules/views/src/Plugin/views/query/QueryPluginBase.php +++ b/core/modules/views/src/Plugin/views/query/QueryPluginBase.php @@ -206,11 +206,17 @@ public function loadEntities(&$results) {} * * @param string $field * The query field that will be used in the expression. + * @param bool $string_date + * For certain databases, date format functions vary depending on string or + * numeric storage. + * @param bool $calculate_offset + * If set to TRUE, the timezone offset will be included in the returned + * field. * * @return string * An expression representing a timestamp with time zone. */ - public function getDateField($field) { + public function getDateField($field, $string_date = FALSE, $calculate_offset = TRUE) { return $field; } @@ -346,6 +352,36 @@ public function getCacheTags() { return []; } + /** + * Applies a timezone offset to the given field. + * + * @param string &$field + * The date field, in string format. + * @param int $offset + * The timezone offset to apply to the field. + */ + public function setFieldTimezoneOffset(&$field, $offset) { + // No-op. Timezone offsets are implementation-specific and should implement + // this method as needed. + } + + /** + * Get the timezone offset in seconds. + * + * @return int + * The offset, in seconds, for the timezone being used. + */ + public function getTimezoneOffset() { + $timezone = $this->setupTimezone(); + $offset = 0; + if ($timezone) { + $dtz = new \DateTimeZone($timezone); + $dt = new \DateTime('now', $dtz); + $offset = $dtz->getOffset($dt); + } + return $offset; + } + } /** diff --git a/core/modules/views/src/Plugin/views/query/Sql.php b/core/modules/views/src/Plugin/views/query/Sql.php index 7df6521b1cbb5529f419f4954197c5b871d79ff1..2771a3d7c035a53a6de91c488886b0fec8f7a24f 100644 --- a/core/modules/views/src/Plugin/views/query/Sql.php +++ b/core/modules/views/src/Plugin/views/query/Sql.php @@ -123,6 +123,13 @@ class Sql extends QueryPluginBase { */ protected $entityTypeManager; + /** + * The database-specific date handler. + * + * @var \Drupal\views\Plugin\views\query\DateSqlInterface + */ + protected $dateSql; + /** * Constructs a Sql object. * @@ -134,11 +141,14 @@ class Sql extends QueryPluginBase { * The plugin implementation definition. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. + * @param \Drupal\views\Plugin\views\query\DateSqlInterface $date_sql + * The database-specific date handler. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, DateSqlInterface $date_sql) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityTypeManager = $entity_type_manager; + $this->dateSql = $date_sql; } public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { @@ -146,7 +156,8 @@ public static function create(ContainerInterface $container, array $configuratio $configuration, $plugin_id, $plugin_definition, - $container->get('entity_type.manager') + $container->get('entity_type.manager'), + $container->get('views.date_sql') ); } @@ -1762,175 +1773,40 @@ public function aggregationMethodDistinct($group_type, $field) { /** * {@inheritdoc} */ - public function getDateField($field) { - $db_type = Database::getConnection()->databaseType(); - $offset = $this->setupTimezone(); - if (isset($offset) && !is_numeric($offset)) { - $dtz = new \DateTimeZone($offset); - $dt = new \DateTime('now', $dtz); - $offset_seconds = $dtz->getOffset($dt); - } - - switch ($db_type) { - case 'mysql': - $field = "DATE_ADD('19700101', INTERVAL $field SECOND)"; - if (!empty($offset)) { - $field = "($field + INTERVAL $offset_seconds SECOND)"; - } - break; - case 'pgsql': - $field = "TO_TIMESTAMP($field)"; - if (!empty($offset)) { - $field = "($field + INTERVAL '$offset_seconds SECONDS')"; - } - break; - case 'sqlite': - if (!empty($offset)) { - $field = "($field + $offset_seconds)"; - } - break; + public function getDateField($field, $string_date = FALSE, $calculate_offset = TRUE) { + $field = $this->dateSql->getDateField($field, $string_date); + if ($calculate_offset && $offset = $this->getTimezoneOffset()) { + $this->setFieldTimezoneOffset($field, $offset); } - return $field; } /** * {@inheritdoc} */ - public function setupTimezone() { - $timezone = drupal_get_user_timezone(); - - // set up the database timezone - $db_type = Database::getConnection()->databaseType(); - if (in_array($db_type, ['mysql', 'pgsql'])) { - $offset = '+00:00'; - static $already_set = FALSE; - if (!$already_set) { - if ($db_type == 'pgsql') { - Database::getConnection()->query("SET TIME ZONE INTERVAL '$offset' HOUR TO MINUTE"); - } - elseif ($db_type == 'mysql') { - Database::getConnection()->query("SET @@session.time_zone = '$offset'"); - } + public function setFieldTimezoneOffset(&$field, $offset) { + $this->dateSql->setFieldTimezoneOffset($field, $offset); + } - $already_set = TRUE; - } + /** + * {@inheritdoc} + */ + public function setupTimezone() { + // Set the database timezone offset. + static $already_set = FALSE; + if (!$already_set) { + $this->dateSql->setTimezoneOffset('+00:00'); + $already_set = TRUE; } - return $timezone; + return parent::setupTimezone(); } /** * {@inheritdoc} */ public function getDateFormat($field, $format, $string_date = FALSE) { - $db_type = Database::getConnection()->databaseType(); - switch ($db_type) { - case 'mysql': - $replace = [ - 'Y' => '%Y', - 'y' => '%y', - 'M' => '%b', - 'm' => '%m', - 'n' => '%c', - 'F' => '%M', - 'D' => '%a', - 'd' => '%d', - 'l' => '%W', - 'j' => '%e', - 'W' => '%v', - 'H' => '%H', - 'h' => '%h', - 'i' => '%i', - 's' => '%s', - 'A' => '%p', - ]; - $format = strtr($format, $replace); - return "DATE_FORMAT($field, '$format')"; - case 'pgsql': - $replace = [ - 'Y' => 'YYYY', - 'y' => 'YY', - 'M' => 'Mon', - 'm' => 'MM', - // No format for Numeric representation of a month, without leading - // zeros. - 'n' => 'MM', - 'F' => 'Month', - 'D' => 'Dy', - 'd' => 'DD', - 'l' => 'Day', - // No format for Day of the month without leading zeros. - 'j' => 'DD', - 'W' => 'IW', - 'H' => 'HH24', - 'h' => 'HH12', - 'i' => 'MI', - 's' => 'SS', - 'A' => 'AM', - ]; - $format = strtr($format, $replace); - if (!$string_date) { - return "TO_CHAR($field, '$format')"; - } - // In order to allow for partials (eg, only the year), transform to a - // date, back to a string again. - return "TO_CHAR(TO_TIMESTAMP($field, 'YYYY-MM-DD HH24:MI:SS'), '$format')"; - case 'sqlite': - $replace = [ - 'Y' => '%Y', - // No format for 2 digit year number. - 'y' => '%Y', - // No format for 3 letter month name. - 'M' => '%m', - 'm' => '%m', - // No format for month number without leading zeros. - 'n' => '%m', - // No format for full month name. - 'F' => '%m', - // No format for 3 letter day name. - 'D' => '%d', - 'd' => '%d', - // No format for full day name. - 'l' => '%d', - // no format for day of month number without leading zeros. - 'j' => '%d', - 'W' => '%W', - 'H' => '%H', - // No format for 12 hour hour with leading zeros. - 'h' => '%H', - 'i' => '%M', - 's' => '%S', - // No format for AM/PM. - 'A' => '', - ]; - $format = strtr($format, $replace); - - // Don't use the 'unixepoch' flag for string date comparisons. - $unixepoch = $string_date ? '' : ", 'unixepoch'"; - - // SQLite does not have a ISO week substitution string, so it needs - // special handling. - // @see http://wikipedia.org/wiki/ISO_week_date#Calculation - // @see http://stackoverflow.com/a/15511864/1499564 - if ($format === '%W') { - $expression = "((strftime('%j', date(strftime('%Y-%m-%d', $field" . $unixepoch . "), '-3 days', 'weekday 4')) - 1) / 7 + 1)"; - } - else { - $expression = "strftime('$format', $field" . $unixepoch . ")"; - } - // The expression yields a string, but the comparison value is an - // integer in case the comparison value is a float, integer, or numeric. - // All of the above SQLite format tokens only produce integers. However, - // the given $format may contain 'Y-m-d', which results in a string. - // @see \Drupal\Core\Database\Driver\sqlite\Connection::expandArguments() - // @see http://www.sqlite.org/lang_datefunc.html - // @see http://www.sqlite.org/lang_expr.html#castexpr - if (preg_match('/^(?:%\w)+$/', $format)) { - $expression = "CAST($expression AS NUMERIC)"; - } - return $expression; - } + return $this->dateSql->getDateFormat($field, $format); } } diff --git a/core/modules/views/src/Plugin/views/query/SqliteDateSql.php b/core/modules/views/src/Plugin/views/query/SqliteDateSql.php new file mode 100644 index 0000000000000000000000000000000000000000..628e1c68faec5c2a3613bc710ce814cb37b009fb --- /dev/null +++ b/core/modules/views/src/Plugin/views/query/SqliteDateSql.php @@ -0,0 +1,122 @@ +<?php + +namespace Drupal\views\Plugin\views\query; + +use Drupal\Core\Database\Connection; + +/** + * SQLite-specific date handling. + * + * @internal + * This class should only be used by the Views SQL query plugin. + * + * @see \Drupal\views\Plugin\views\query\Sql + */ +class SqliteDateSql implements DateSqlInterface { + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * An array of PHP-to-SQLite date replacement patterns. + * + * @var array + */ + protected static $replace = [ + 'Y' => '%Y', + // No format for 2 digit year number. + 'y' => '%Y', + // No format for 3 letter month name. + 'M' => '%m', + 'm' => '%m', + // No format for month number without leading zeros. + 'n' => '%m', + // No format for full month name. + 'F' => '%m', + // No format for 3 letter day name. + 'D' => '%d', + 'd' => '%d', + // No format for full day name. + 'l' => '%d', + // no format for day of month number without leading zeros. + 'j' => '%d', + 'W' => '%W', + 'H' => '%H', + // No format for 12 hour hour with leading zeros. + 'h' => '%H', + 'i' => '%M', + 's' => '%S', + // No format for AM/PM. + 'A' => '', + ]; + + /** + * Constructs the SQLite-specific date sql class. + * + * @param \Drupal\Core\Database\Connection $database + * The database connection. + */ + public function __construct(Connection $database) { + $this->database = $database; + } + + /** + * {@inheritdoc} + */ + public function getDateField($field, $string_date) { + if ($string_date) { + $field = "strftime('%s', $field)"; + } + return $field; + } + + /** + * {@inheritdoc} + */ + public function getDateFormat($field, $format) { + $format = strtr($format, static::$replace); + + // SQLite does not have a ISO week substitution string, so it needs special + // handling. + // @see http://wikipedia.org/wiki/ISO_week_date#Calculation + // @see http://stackoverflow.com/a/15511864/1499564 + if ($format === '%W') { + $expression = "((strftime('%j', date(strftime('%Y-%m-%d', $field, 'unixepoch'), '-3 days', 'weekday 4')) - 1) / 7 + 1)"; + } + else { + $expression = "strftime('$format', $field, 'unixepoch')"; + } + // The expression yields a string, but the comparison value is an integer in + // case the comparison value is a float, integer, or numeric. All of the + // above SQLite format tokens only produce integers. However, the given + // $format may contain 'Y-m-d', which results in a string. + // @see \Drupal\Core\Database\Driver\sqlite\Connection::expandArguments() + // @see http://www.sqlite.org/lang_datefunc.html + // @see http://www.sqlite.org/lang_expr.html#castexpr + if (preg_match('/^(?:%\w)+$/', $format)) { + $expression = "CAST($expression AS NUMERIC)"; + } + return $expression; + } + + /** + * {@inheritdoc} + */ + public function setTimezoneOffset($offset) { + // Nothing to do here. + } + + /** + * {@inheritdoc} + */ + public function setFieldTimezoneOffset(&$field, $offset, $string_date = FALSE) { + if (!empty($offset)) { + $field = "($field + $offset)"; + } + } + +} diff --git a/core/modules/views/tests/modules/views_test_data/src/Plugin/views/query/QueryTest.php b/core/modules/views/tests/modules/views_test_data/src/Plugin/views/query/QueryTest.php index cf430af25c094dee4b8857d18abd6720b269d8b8..8868f75de667a3d3941129c8819c814a10da798e 100644 --- a/core/modules/views/tests/modules/views_test_data/src/Plugin/views/query/QueryTest.php +++ b/core/modules/views/tests/modules/views_test_data/src/Plugin/views/query/QueryTest.php @@ -151,4 +151,9 @@ public function calculateDependencies() { ]; } + /** + * {@inheritdoc} + */ + public function setFieldTimezoneOffset(&$field, $offset) {} + } diff --git a/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php index bae90240f0057d9cb59e186b9bbd21a642df0e5f..80e8a334ef6a6ba9fb120b245494c17efbe8b50e 100644 --- a/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/query/SqlTest.php @@ -7,6 +7,7 @@ use Drupal\Core\Entity\EntityType; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Tests\UnitTestCase; +use Drupal\views\Plugin\views\query\DateSqlInterface; use Drupal\views\Plugin\views\query\Sql; use Drupal\views\Plugin\views\relationship\RelationshipPluginBase; use Drupal\views\ResultRow; @@ -29,8 +30,9 @@ class SqlTest extends UnitTestCase { public function testGetCacheTags() { $view = $this->prophesize('Drupal\views\ViewExecutable')->reveal(); $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -75,8 +77,9 @@ public function testGetCacheTags() { public function testGetCacheMaxAge() { $view = $this->prophesize('Drupal\views\ViewExecutable')->reveal(); $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $view->result = []; @@ -249,8 +252,9 @@ public function testLoadEntitiesWithEmptyResult() { $view->storage = $view_entity->reveal(); $entity_type_manager = $this->setupEntityTypes(); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -277,8 +281,9 @@ public function testLoadEntitiesWithNoRelationshipAndNoRevision() { ], ]; $entity_type_manager = $this->setupEntityTypes($entities); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -340,8 +345,9 @@ public function testLoadEntitiesWithRelationship() { ], ]; $entity_type_manager = $this->setupEntityTypes($entities); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -394,8 +400,9 @@ public function testLoadEntitiesWithNonEntityRelationship() { ], ]; $entity_type_manager = $this->setupEntityTypes($entities); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -444,8 +451,9 @@ public function testLoadEntitiesWithRevision() { ], ]; $entity_type_manager = $this->setupEntityTypes([], $entity_revisions); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -497,8 +505,9 @@ public function testLoadEntitiesWithRevisionOfSameEntityType() { ], ]; $entity_type_manager = $this->setupEntityTypes($entity, $entity_revisions); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; @@ -554,8 +563,9 @@ public function testLoadEntitiesWithRelationshipAndRevision() { ], ]; $entity_type_manager = $this->setupEntityTypes($entities, $entity_revisions); + $date_sql = $this->prophesize(DateSqlInterface::class); - $query = new Sql([], 'sql', [], $entity_type_manager->reveal()); + $query = new Sql([], 'sql', [], $entity_type_manager->reveal(), $date_sql->reveal()); $query->view = $view; $result = []; diff --git a/core/modules/views/tests/src/Unit/Plugin/views/query/MysqlDateSqlTest.php b/core/modules/views/tests/src/Unit/Plugin/views/query/MysqlDateSqlTest.php new file mode 100644 index 0000000000000000000000000000000000000000..575cc58cf690f4b1515310fd2b94b0bbf9286cb6 --- /dev/null +++ b/core/modules/views/tests/src/Unit/Plugin/views/query/MysqlDateSqlTest.php @@ -0,0 +1,97 @@ +<?php + +namespace Drupal\Tests\views\Unit\Plugin\views\query; + +use Drupal\Core\Database\Connection; +use Drupal\Tests\UnitTestCase; +use Drupal\views\Plugin\views\query\MysqlDateSql; + +/** + * Tests the MySQL-specific date query handler. + * + * @coversDefaultClass \Drupal\views\Plugin\views\query\MysqlDateSql + * + * @group views + */ +class MysqlDateSqlTest extends UnitTestCase { + + /** + * The mocked database service. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + $this->database = $this->prophesize(Connection::class)->reveal(); + } + + /** + * Tests the getDateField method. + * + * @covers ::getDateField + */ + public function testGetDateField() { + $date_sql = new MysqlDateSql($this->database); + + $expected = 'foo.field'; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', TRUE)); + + $expected = "DATE_ADD('19700101', INTERVAL foo.field SECOND)"; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', FALSE)); + } + + /** + * Tests date formatting replacement. + * + * @covers ::getDateFormat + * + * @dataProvider providerTestGetDateFormat + */ + public function testGetDateFormat($field, $format, $expected_format) { + $date_sql = new MysqlDateSql($this->database); + + $this->assertEquals("DATE_FORMAT($field, '$expected_format')", $date_sql->getDateFormat($field, $format)); + } + + /** + * Provider for date formatting test. + */ + public function providerTestGetDateFormat() { + return [ + ['foo.field', 'Y-y-M-m', '%Y-%y-%b-%m'], + ['bar.field', 'n-F D d l', '%c-%M %a %d %W'], + ['baz.bar_field', 'j/W/H-h i s A', '%e/%v/%H-%h %i %s %p'], + ]; + } + + /** + * Tests timezone offset formatting. + * + * @covers ::setFieldTimezoneOffset + */ + public function testSetFieldTimezoneOffset() { + $date_sql = new MysqlDateSql($this->database); + + $field = 'foobar.field'; + $date_sql->setFieldTimezoneOffset($field, 42); + $this->assertEquals("(foobar.field + INTERVAL 42 SECOND)", $field); + } + + /** + * Tests setting the database offset. + * + * @covers ::setTimezoneOffset + */ + public function testSetTimezoneOffset() { + $database = $this->prophesize(Connection::class); + $database->query("SET @@session.time_zone = '42'")->shouldBeCalledTimes(1); + $date_sql = new MysqlDateSql($database->reveal()); + $date_sql->setTimezoneOffset(42); + } + +} diff --git a/core/modules/views/tests/src/Unit/Plugin/views/query/PostgresqlDateSqlTest.php b/core/modules/views/tests/src/Unit/Plugin/views/query/PostgresqlDateSqlTest.php new file mode 100644 index 0000000000000000000000000000000000000000..14f367d261dce4e3d449898f13c4ce35cf4800ce --- /dev/null +++ b/core/modules/views/tests/src/Unit/Plugin/views/query/PostgresqlDateSqlTest.php @@ -0,0 +1,97 @@ +<?php + +namespace Drupal\Tests\views\Unit\Plugin\views\query; + +use Drupal\Core\Database\Connection; +use Drupal\Tests\UnitTestCase; +use Drupal\views\Plugin\views\query\PostgresqlDateSql; + +/** + * Tests the PostgreSQL-specific date query handler. + * + * @coversDefaultClass \Drupal\views\Plugin\views\query\PostgresqlDateSql + * + * @group views + */ +class PostgresqlDateSqlTest extends UnitTestCase { + + /** + * The mocked database service. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + $this->database = $this->prophesize(Connection::class)->reveal(); + } + + /** + * Tests the getDateField method. + * + * @covers ::getDateField + */ + public function testGetDateField() { + $date_sql = new PostgresqlDateSql($this->database); + + $expected = "TO_TIMESTAMP(foo.field, 'YYYY-MM-DD HH24:MI:SS')"; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', TRUE)); + + $expected = 'TO_TIMESTAMP(foo.field)'; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', FALSE)); + } + + /** + * Tests date formatting replacement. + * + * @covers ::getDateFormat + * + * @dataProvider providerTestGetDateFormat + */ + public function testGetDateFormat($field, $format, $expected_format) { + $date_sql = new PostgresqlDateSql($this->database); + + $this->assertEquals("TO_CHAR($field, '$expected_format')", $date_sql->getDateFormat($field, $format)); + } + + /** + * Provider for date formatting test. + */ + public function providerTestGetDateFormat() { + return [ + ['foo.field', 'Y-y-M-m', 'YYYY-YY-Mon-MM'], + ['bar.field', 'n-F D d l', 'MM-Month Dy DD Day'], + ['baz.bar_field', 'j/W/H-h i s A', 'DD/IW/HH24-HH12 MI SS AM'], + ]; + } + + /** + * Tests timezone offset formatting. + * + * @covers ::setFieldTimezoneOffset + */ + public function testSetFieldTimezoneOffset() { + $date_sql = new PostgresqlDateSql($this->database); + + $field = 'foobar.field'; + $date_sql->setFieldTimezoneOffset($field, 42); + $this->assertEquals("(foobar.field + INTERVAL '42 SECONDS')", $field); + } + + /** + * Tests setting the database offset. + * + * @covers ::setTimezoneOffset + */ + public function testSetTimezoneOffset() { + $database = $this->prophesize(Connection::class); + $database->query("SET TIME ZONE INTERVAL '42' HOUR TO MINUTE")->shouldBeCalledTimes(1); + $date_sql = new PostgresqlDateSql($database->reveal()); + $date_sql->setTimezoneOffset(42); + } + +} diff --git a/core/modules/views/tests/src/Unit/Plugin/views/query/SqliteDateSqlTest.php b/core/modules/views/tests/src/Unit/Plugin/views/query/SqliteDateSqlTest.php new file mode 100644 index 0000000000000000000000000000000000000000..f29650796c5631bd14f443166ff75abfaa85c5cc --- /dev/null +++ b/core/modules/views/tests/src/Unit/Plugin/views/query/SqliteDateSqlTest.php @@ -0,0 +1,98 @@ +<?php + +namespace Drupal\Tests\views\Unit\Plugin\views\query; + +use Drupal\Core\Database\Connection; +use Drupal\Tests\UnitTestCase; +use Drupal\views\Plugin\views\query\SqliteDateSql; + +/** + * Tests the MySQL-specific date query handler. + * + * @coversDefaultClass \Drupal\views\Plugin\views\query\SqliteDateSql + * + * @group views + */ +class SqliteDateSqlTest extends UnitTestCase { + + /** + * The mocked database service. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + $this->database = $this->prophesize(Connection::class)->reveal(); + } + + /** + * Tests the getDateField method. + * + * @covers ::getDateField + */ + public function testGetDateField() { + $date_sql = new SqliteDateSql($this->database); + + $expected = "strftime('%s', foo.field)"; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', TRUE)); + + $expected = 'foo.field'; + $this->assertEquals($expected, $date_sql->getDateField('foo.field', FALSE)); + } + + /** + * Tests date formatting replacement. + * + * @covers ::getDateFormat + * + * @dataProvider providerTestGetDateFormat + */ + public function testGetDateFormat($field, $format, $expected) { + $date_sql = new SqliteDateSql($this->database); + + $this->assertEquals($expected, $date_sql->getDateFormat($field, $format)); + } + + /** + * Provider for date formatting test. + */ + public function providerTestGetDateFormat() { + return [ + ['foo.field', 'Y-y-M-m', "strftime('%Y-%Y-%m-%m', foo.field, 'unixepoch')"], + ['bar.field', 'n-F D d l', "strftime('%m-%m %d %d %d', bar.field, 'unixepoch')"], + ['baz.bar_field', 'j/W/H-h i s A', "strftime('%d/%W/%H-%H %M %S ', baz.bar_field, 'unixepoch')"], + ['foo.field', 'W', "CAST(((strftime('%j', date(strftime('%Y-%m-%d', foo.field, 'unixepoch'), '-3 days', 'weekday 4')) - 1) / 7 + 1) AS NUMERIC)"] + ]; + } + + /** + * Tests timezone offset formatting. + * + * @covers ::setFieldTimezoneOffset + */ + public function testSetFieldTimezoneOffset() { + $date_sql = new SqliteDateSql($this->database); + + $field = 'foobar.field'; + $date_sql->setFieldTimezoneOffset($field, 42); + $this->assertEquals("(foobar.field + 42)", $field); + } + + /** + * Tests setting the database offset. + * + * @covers ::setTimezoneOffset + */ + public function testSetTimezoneOffset() { + $database = $this->prophesize(Connection::class); + $database->query()->shouldNotBeCalled(); + $date_sql = new SqliteDateSql($database->reveal()); + $date_sql->setTimezoneOffset(42); + } + +} diff --git a/core/modules/views/views.services.yml b/core/modules/views/views.services.yml index 28f8d0d333d35882d1e6659203f87d4995da94ca..ffed39460f83d13e22cd38bd921b42e8af5a96ca 100644 --- a/core/modules/views/views.services.yml +++ b/core/modules/views/views.services.yml @@ -80,3 +80,16 @@ services: arguments: ['@entity.manager'] tags: - { name: 'event_subscriber' } + views.date_sql: + class: Drupal\views\Plugin\views\query\MysqlDateSql + arguments: ['@database'] + tags: + - { name: backend_overridable } + pgsql.views.date_sql: + class: Drupal\views\Plugin\views\query\PostgresqlDateSql + arguments: ['@database'] + public: false + sqlite.views.date_sql: + class: Drupal\views\Plugin\views\query\SqliteDateSql + arguments: ['@database'] + public: false