Skip to content

Fix - Use an indexable range for date search criteria - #25096

Open
RomainLvr wants to merge 1 commit into
glpi-project:11.0/bugfixesfrom
RomainLvr:fix/date-search-index-usage
Open

Fix - Use an indexable range for date search criteria#25096
RomainLvr wants to merge 1 commit into
glpi-project:11.0/bugfixesfrom
RomainLvr:fix/date-search-index-usage

Conversation

@RomainLvr

Copy link
Copy Markdown
Contributor

Checklist before requesting a review

Please delete options that are not relevant.

  • I have read the CONTRIBUTING document.
  • I have performed a self-review of my code.
  • I have added tests that prove my fix is effective or that my feature works.
  • This change requires a documentation update.

Description

  • It fixes !45581
  • Here is a brief description of what this PR does

Searching on a date field does not use the index defined on the column. On a large glpi_tickets table, filtering on a single day reads the whole table instead of a few hundred rows.

Cause

Both the "is" and the "contains" criteria on a date/datetime field were built as a LIKE pattern:

WHERE `glpi_tickets`.`date` LIKE '2026-07-28%'
WHERE CONVERT(`glpi_tickets`.`date` USING utf8mb4) LIKE '%2026-07%'

Comparing a date/time column with a LIKE pattern forces MySQL to cast it into a string, which makes the index unusable. Even FORCE INDEX cannot help.

Fix

Both criteria now search the range matching the precision of the searched value:

WHERE `glpi_tickets`.`date` >= '2026-07-28 00:00:00' AND `glpi_tickets`.`date` < '2026-07-29 00:00:00'

EXPLAIN goes from type=ALL (full table scan) to type=range using the column index.

Results are unchanged. The previous LIKE form is still used when the searched value cannot be interpreted as a date prefix (-07-, 2026-02-30, NULL), on computed fields, and on date_delay fields.

Tests

SearchTest covers both criteria for every precision, in the positive and negated form, plus the values that must keep the LIKE form.

@RomainLvr RomainLvr self-assigned this Aug 5, 2026
@RomainLvr
RomainLvr requested a review from Rom1-B August 5, 2026 14:35
Comment on lines +5499 to +5500
// `!` resets unspecified fields to their "zero" value
$lower_bound = DateTimeImmutable::createFromFormat('!' . $format, $val);

@Rom1-B Rom1-B Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a DST spring-forward gap hour (e.g. 2024-03-31 02 in Europe/Paris), the parsed hour silently shifts with no warning, so the search range misses a row whose naive datetime column holds that literal value.

diff --git a/src/Glpi/Search/Provider/SQLProvider.php b/src/Glpi/Search/Provider/SQLProvider.php
index 50fddf4df6..390fd6a714 100644
--- a/src/Glpi/Search/Provider/SQLProvider.php
+++ b/src/Glpi/Search/Provider/SQLProvider.php
@@ -50,6 +50,7 @@ use Consumable;
 use CronTask;
 use DateInterval;
 use DateTimeImmutable;
+use DateTimeZone;
 use DBConnection;
 use DBmysql;
 use DBmysqlIterator;
@@ -5496,8 +5497,8 @@ final class SQLProvider implements SearchProviderInterface
                 return null;
             }
 
-            // `!` resets unspecified fields to their "zero" value
-            $lower_bound = DateTimeImmutable::createFromFormat('!' . $format, $val);
+            // `!` resets unspecified fields to their "zero" value; force UTC as the value has no time offset.
+            $lower_bound = DateTimeImmutable::createFromFormat('!' . $format, $val, new DateTimeZone('UTC'));
             $errors      = DateTimeImmutable::getLastErrors();
             if ($lower_bound === false || ($errors !== false && ($errors['warning_count'] + $errors['error_count']) > 0)) {
                 // Out of range value, e.g. `2024-02-30`
diff --git a/tests/functional/SearchTest.php b/tests/functional/SearchTest.php
index 08dc26c77a..005c5a4b29 100644
--- a/tests/functional/SearchTest.php
+++ b/tests/functional/SearchTest.php
@@ -4725,6 +4725,43 @@ class SearchTest extends DbTestCase
         }
     }
 
+    /**
+     * The range boundaries must not be shifted by a DST transition of the server timezone,
+     * as the compared `datetime` column holds a naive value with no time offset.
+     */
+    public function testDateTimeEqualsCriterionOnDstTransition(): void
+    {
+        global $DB;
+
+        $original_tz = date_default_timezone_get();
+        // Hack to prevent the script tz from being changed by the DB access layer
+        $DB->use_timezones = true;
+        // Clocks jump from 02:00 to 03:00 in `Europe/Paris` on this date
+        date_default_timezone_set('Europe/Paris');
+
+        try {
+            $data = $this->doSearch(Ticket::class, [
+                'is_deleted' => 0,
+                'start'      => 0,
+                'criteria'   => [
+                    [
+                        'link'       => 'AND',
+                        'field'      => 15, // date
+                        'searchtype' => 'equals',
+                        'value'      => '2024-03-31 02',
+                    ],
+                ],
+            ]);
+        } finally {
+            date_default_timezone_set($original_tz);
+        }
+
+        $this->assertStringContainsString(
+            "(`glpi_tickets`.`date` >= '2024-03-31 02:00:00') AND (`glpi_tickets`.`date` < '2024-03-31 03:00:00')",
+            $this->cleanSQL($data['sql']['search'])
+        );
+    }
+
     protected function customAssetsProvider(): iterable
     {
         $root_entity_id = getItemByTypeName('Entity', '_test_root_entity', true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants