Skip to content

Instantly share code, notes, and snippets.

@junaidpv
Last active July 22, 2025 17:31
Show Gist options
  • Save junaidpv/324c14aa89670c9de82d1721b53f7bb4 to your computer and use it in GitHub Desktop.
Save junaidpv/324c14aa89670c9de82d1721b53f7bb4 to your computer and use it in GitHub Desktop.
diff --git a/core/modules/datetime/src/Plugin/views/filter/Date.php b/core/modules/datetime/src/Plugin/views/filter/Date.php
index 39cb6d931..0c7d3b91c 100644
--- a/core/modules/datetime/src/Plugin/views/filter/Date.php
+++ b/core/modules/datetime/src/Plugin/views/filter/Date.php
@@ -58,6 +58,20 @@ class Date extends NumericDate implements ContainerFactoryPluginInterface {
*/
protected $requestStack;
+ /**
+ * Mapping of granularity values to their corresponding date formats.
+ *
+ * @var array
+ */
+ protected $dateFormats = [
+ 'second' => 'Y-m-d\TH:i:s',
+ 'minute' => 'Y-m-d\TH:i',
+ 'hour' => 'Y-m-d\TH',
+ 'day' => 'Y-m-d',
+ 'month' => 'Y-m',
+ 'year' => 'Y',
+ ];
+
/**
* Constructs a new Date handler.
*
@@ -149,6 +163,61 @@ public function validateExposed(&$form, FormStateInterface $form_state): void {
}
}
+ /**
+ * {@inheritdoc}
+ */
+ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
+ parent::buildOptionsForm($form, $form_state);
+
+ $options = [];
+ // Only show granularity options for times if the field supports a time.
+ if ($this->fieldStorageDefinition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATETIME) {
+ $options = [
+ 'second' => $this->t('Second'),
+ 'minute' => $this->t('Minute'),
+ 'hour' => $this->t('Hour'),
+ ];
+ }
+ // All fields (datetime or date-only) will need these options.
+ $options['day'] = $this->t('Day');
+ $options['month'] = $this->t('Month');
+ $options['year'] = $this->t('Year');
+
+ $form['granularity'] = [
+ '#type' => 'radios',
+ '#title' => $this->t('Granularity'),
+ '#options' => $options,
+ '#description' => $this->t('The granularity is the smallest unit to use when determining whether two dates are the same; for example, if the granularity is "Year" then all dates in 1999, regardless of when they fall in 1999, will be considered the same date.'),
+ '#default_value' => $this->options['granularity'],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function defineOptions() {
+ $options = parent::defineOptions();
+
+ $options['granularity'] = [
+ // The default depends on if the field is date-only or includes time.
+ 'default' => $this->fieldStorageDefinition->getSetting('datetime_type') === DateTimeItem::DATETIME_TYPE_DATETIME ? 'second' : 'day',
+ ];
+
+ return $options;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function query() {
+ // Set the date format based on granularity.
+ if (isset($this->dateFormats[$this->options['granularity']])) {
+ $this->dateFormat = $this->dateFormats[$this->options['granularity']];
+ }
+
+ parent::query();
+ }
+
/**
* Override parent method, which deals with dates as integers.
*/
@@ -165,12 +234,42 @@ protected function opBetween($field) {
$this->value['max'] .= ' +1 day';
}
+ $max = $this->value['max'];
+
+ // If year granularity is specified, then suffix with month and day to
+ // force DateTimePlus to treat the input as a proper date value.
+ if ($this->options['granularity'] == 'year') {
+ $min = preg_replace('/^(\d{4})$/', '$1-01-01', $min);
+ $max = preg_replace('/^(\d{4})$/', '$1-01-01', $max);
+ }
+
// Convert to ISO format and format for query. UTC timezone is used since
// dates are stored in UTC.
$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);
+ $b = new DateTimePlus($max, new \DateTimeZone($timezone));
+
+ // If hour/minute/second granularity is specified, then consider timezone
+ // offset when building the query value.
+ if (in_array($this->options['granularity'], ['hour', 'minute', 'second'])) {
+ $a_timestamp = $a->getTimestamp() + $origin_offset;
+ $b_timestamp = $b->getTimestamp() + $origin_offset;
+ $calculate_offset = $this->calculateOffset;
+ }
+ // Otherwise, do not apply any timezone offset, because day/month/year
+ // granularity will effectively cause the provided datetime to have a
+ // "midnight" hour value, and further adjustments could cause the query to
+ // return results from one day prior to the expected day.
+ // The query will still apply any applicable timezone offset to the values
+ // of the datetime field in the database before performing a comparison
+ // (see below).
+ else {
+ $a_timestamp = $a->getTimestamp();
+ $b_timestamp = $b->getTimestamp();
+ $calculate_offset = FALSE;
+ }
+
+ $a = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($a_timestamp, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $calculate_offset), $this->dateFormat, TRUE);
+ $b = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($b_timestamp, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $calculate_offset), $this->dateFormat, TRUE);
// This is safe because we are manually scrubbing the values.
$operator = strtoupper($this->operator);
@@ -185,9 +284,34 @@ protected function opSimple($field) {
$timezone = $this->getTimezone();
$origin_offset = $this->getOffset($this->value['value'], $timezone);
+ // If year granularity is specified, then suffix with month and day to
+ // force DateTimePlus to treat the input as a proper date value.
+ $date_value = $this->value['value'];
+ if ($this->options['granularity'] == 'year') {
+ $date_value = preg_replace('/^(\d{4})$/', '$1-01-01', $date_value);
+ }
+
// 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);
+ $value = new DateTimePlus($date_value, new \DateTimeZone($timezone));
+ // If hour/minute/second granularity is specified, then consider timezone
+ // offset when building the query value.
+ if (in_array($this->options['granularity'], ['hour', 'minute', 'second'])) {
+ $timestamp = $value->getTimestamp() + $origin_offset;
+ $calculate_offset = $this->calculateOffset;
+ }
+ // Otherwise, do not apply any timezone offset, because day/month/year
+ // granularity will effectively cause the provided datetime to have a
+ // "midnight" hour value, and further adjustments could cause the query to
+ // return results from one day prior to the expected day.
+ // The query will still apply any applicable timezone offset to the values
+ // of the datetime field in the database before performing a comparison
+ // (see below).
+ else {
+ $timestamp = $value->getTimestamp();
+ $calculate_offset = FALSE;
+ }
+
+ $value = $this->query->getDateFormat($this->query->getDateField("'" . $this->dateFormatter->format($timestamp, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, DateTimeItemInterface::STORAGE_TIMEZONE) . "'", TRUE, $calculate_offset), $this->dateFormat, TRUE);
// This is safe because we are manually scrubbing the value.
$field = $this->query->getDateFormat($this->query->getDateField($field, TRUE, $this->calculateOffset), $this->dateFormat, TRUE);
diff --git a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php
index 4d4997b2b..a9efead82 100644
--- a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php
+++ b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTest.php
@@ -112,7 +112,7 @@ public function testDateOffsets(): void {
['nid' => $this->nodes[0]->id()],
['nid' => $this->nodes[1]->id()],
];
- $this->assertIdenticalResultset($view, $expected_result, $this->map);
+ $this->assertIdenticalResultset($view, $expected_result, $this->map, "Unexpected result set for $timezone");
$view->destroy();
// Only dates in the past.
@@ -125,7 +125,7 @@ public function testDateOffsets(): void {
$expected_result = [
['nid' => $this->nodes[2]->id()],
];
- $this->assertIdenticalResultset($view, $expected_result, $this->map);
+ $this->assertIdenticalResultset($view, $expected_result, $this->map, "Unexpected result set for $timezone");
$view->destroy();
// Test offset for between operator. Only 'tomorrow' node should appear.
@@ -139,7 +139,7 @@ public function testDateOffsets(): void {
$expected_result = [
['nid' => $this->nodes[0]->id()],
];
- $this->assertIdenticalResultset($view, $expected_result, $this->map);
+ $this->assertIdenticalResultset($view, $expected_result, $this->map, "Unexpected result set for $timezone");
$view->destroy();
// Test the empty operator.
@@ -150,7 +150,7 @@ public function testDateOffsets(): void {
$expected_result = [
['nid' => $this->nodes[3]->id()],
];
- $this->assertIdenticalResultset($view, $expected_result, $this->map);
+ $this->assertIdenticalResultset($view, $expected_result, $this->map, "Unexpected result set for $timezone");
$view->destroy();
// Test the not empty operator.
@@ -163,7 +163,7 @@ public function testDateOffsets(): void {
['nid' => $this->nodes[1]->id()],
['nid' => $this->nodes[2]->id()],
];
- $this->assertIdenticalResultset($view, $expected_result, $this->map);
+ $this->assertIdenticalResultset($view, $expected_result, $this->map, "Unexpected result set for $timezone");
$view->destroy();
}
}
diff --git a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php
index 5861acf8c..a791922e7 100644
--- a/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php
+++ b/core/modules/datetime/tests/src/Kernel/Views/FilterDateTimeTest.php
@@ -189,6 +189,7 @@ protected function _testExact() {
$view->filter[$field]->operator = '=';
$view->filter[$field]->value['min'] = '';
$view->filter[$field]->value['max'] = '';
+ $view->filter[$field]->options['granularity'] = 'second';
// Use the date from node 3. Use the site timezone (mimics a value entered
// through the UI).
$view->filter[$field]->value['value'] = \Drupal::service('date.formatter')->format(static::$date, 'custom', DateTimeItemInterface::DATETIME_STORAGE_FORMAT, static::$timezone);
diff --git a/core/modules/views/config/schema/views.filter.schema.yml b/core/modules/views/config/schema/views.filter.schema.yml
index 6eb22b82f..11fb9947b 100644
--- a/core/modules/views/config/schema/views.filter.schema.yml
+++ b/core/modules/views/config/schema/views.filter.schema.yml
@@ -202,6 +202,9 @@ views.filter.date:
type:
type: string
label: 'Type'
+ granularity:
+ type: string
+ label: 'Granularity'
views.filter_value.in_operator:
type: sequence
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment