From af1c76ff5b3e0b5e71bfe83aa56f73ad6c86e7df Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 2 Feb 2026 12:02:06 +0100 Subject: [PATCH 1/2] feat: database session timezone --- app/Config/Database.php | 3 + system/Database/BaseConnection.php | 78 +++++++++++++ system/Database/MySQLi/Connection.php | 41 ++++--- system/Database/OCI8/Connection.php | 12 +- system/Database/Postgre/Connection.php | 6 + tests/system/Database/BaseConnectionTest.php | 89 +++++++++++++++ tests/system/Database/Live/ConnectTest.php | 106 ++++++++++++++++++ .../source/database/configuration.rst | 24 ++++ 8 files changed, 343 insertions(+), 16 deletions(-) diff --git a/app/Config/Database.php b/app/Config/Database.php index d7939bc26f9e..ec6a6df9d173 100644 --- a/app/Config/Database.php +++ b/app/Config/Database.php @@ -49,6 +49,7 @@ class Database extends Config 'datetime' => 'Y-m-d H:i:s', 'time' => 'H:i:s', ], + 'timezone' => false, ]; // /** @@ -98,6 +99,7 @@ class Database extends Config // 'datetime' => 'Y-m-d H:i:s', // 'time' => 'H:i:s', // ], + // 'timezone' => false, // ]; // /** @@ -155,6 +157,7 @@ class Database extends Config // 'datetime' => 'Y-m-d H:i:s', // 'time' => 'H:i:s', // ], + // 'timezone' => false, // ]; /** diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 8e8d0dd43d24..6f3846904565 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -16,6 +16,8 @@ use Closure; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Events\Events; +use CodeIgniter\I18n\Time; +use Exception; use stdClass; use Stringable; use Throwable; @@ -156,6 +158,20 @@ abstract class BaseConnection implements ConnectionInterface */ protected $DBCollat = ''; + /** + * Database session timezone + * + * false = Don't set timezone (default, backward compatible) + * true = Automatically sync with app timezone + * string = Specific timezone (offset or named timezone) + * + * Named timezones (e.g., 'America/New_York') will be automatically + * converted to offsets (e.g., '-05:00') for database compatibility. + * + * @var bool|string + */ + protected $timezone = false; + /** * Swap Prefix * @@ -1915,6 +1931,68 @@ protected function _enableForeignKeyChecks() return ''; } + /** + * Converts a named timezone to an offset string. + * + * Converts timezone identifiers (e.g., 'America/New_York') to offset strings + * (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all + * databases have timezone tables loaded, but all support offset notation. + * + * @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris') + * + * @return string Offset string (e.g., '+00:00', '-05:00', '+01:00') + */ + protected function convertTimezoneToOffset(string $timezone): string + { + // If it's already an offset, return as-is + if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) { + return $timezone; + } + + try { + $offset = Time::now($timezone)->getOffset(); + + // Convert offset seconds to +-HH:MM format + $hours = (int) ($offset / 3600); + $minutes = abs((int) (($offset % 3600) / 60)); + + return sprintf('%+03d:%02d', $hours, $minutes); + } catch (Exception $e) { + // If timezone conversion fails, log and return UTC + log_message('error', "Invalid timezone '{$timezone}': {$e->getMessage()}. Falling back to UTC."); + + return '+00:00'; + } + } + + /** + * Gets the timezone string to use for database session. + * + * Handles the timezone configuration logic: + * - false: Don't set timezone (returns null) + * - true: Auto-sync with app timezone from config + * - string: Use specific timezone (converts named timezones to offsets) + * + * @return string|null The timezone offset string, or null if timezone should not be set + */ + protected function getSessionTimezone(): ?string + { + if ($this->timezone === false) { + return null; + } + + // Auto-sync with app timezone + if ($this->timezone === true) { + $appConfig = config('App'); + $timezone = $appConfig->appTimezone ?? 'UTC'; + } else { + // Use specific timezone from config + $timezone = $this->timezone; + } + + return $this->convertTimezoneToOffset($timezone); + } + /** * Accessor for properties if they exist. * diff --git a/system/Database/MySQLi/Connection.php b/system/Database/MySQLi/Connection.php index b38ef3349eaa..2339469dfc71 100644 --- a/system/Database/MySQLi/Connection.php +++ b/system/Database/MySQLi/Connection.php @@ -123,27 +123,38 @@ public function connect(bool $persistent = false) $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1); } + // Build init command for strictOn and timezone + $initCommands = []; + if ($this->strictOn !== null) { if ($this->strictOn) { - $this->mysqli->options( - MYSQLI_INIT_COMMAND, - "SET SESSION sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')", - ); + $initCommands[] = "sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')"; } else { - $this->mysqli->options( - MYSQLI_INIT_COMMAND, - "SET SESSION sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( - @@sql_mode, - 'STRICT_ALL_TABLES,', ''), - ',STRICT_ALL_TABLES', ''), - 'STRICT_ALL_TABLES', ''), - 'STRICT_TRANS_TABLES,', ''), - ',STRICT_TRANS_TABLES', ''), - 'STRICT_TRANS_TABLES', '')", - ); + $initCommands[] = "sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + @@sql_mode, + 'STRICT_ALL_TABLES,', ''), + ',STRICT_ALL_TABLES', ''), + 'STRICT_ALL_TABLES', ''), + 'STRICT_TRANS_TABLES,', ''), + ',STRICT_TRANS_TABLES', ''), + 'STRICT_TRANS_TABLES', '')"; } } + // Set session timezone if configured + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $initCommands[] = "time_zone = '{$timezoneOffset}'"; + } + + // Set init command if we have any commands + if ($initCommands !== []) { + $this->mysqli->options( + MYSQLI_INIT_COMMAND, + 'SET SESSION ' . implode(', ', $initCommands), + ); + } + if (is_array($this->encrypt)) { $ssl = []; diff --git a/system/Database/OCI8/Connection.php b/system/Database/OCI8/Connection.php index dc884588a251..bbed63526336 100644 --- a/system/Database/OCI8/Connection.php +++ b/system/Database/OCI8/Connection.php @@ -145,9 +145,19 @@ public function connect(bool $persistent = false) $func = $persistent ? 'oci_pconnect' : 'oci_connect'; - return ($this->charset === '') + $this->connID = ($this->charset === '') ? $func($this->username, $this->password, $this->DSN) : $func($this->username, $this->password, $this->DSN, $this->charset); + + // Set session timezone if configured and connection is successful + if ($this->connID !== false) { + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $this->simpleQuery("ALTER SESSION SET TIME_ZONE = '{$timezoneOffset}'"); + } + } + + return $this->connID; } /** diff --git a/system/Database/Postgre/Connection.php b/system/Database/Postgre/Connection.php index 295616ab035d..11b48c474ee5 100644 --- a/system/Database/Postgre/Connection.php +++ b/system/Database/Postgre/Connection.php @@ -97,6 +97,12 @@ public function connect(bool $persistent = false) throw new DatabaseException($error); } + + // Set session timezone if configured + $timezoneOffset = $this->getSessionTimezone(); + if ($timezoneOffset !== null) { + $this->simpleQuery("SET TIME ZONE '{$timezoneOffset}'"); + } } return $this->connID; diff --git a/tests/system/Database/BaseConnectionTest.php b/tests/system/Database/BaseConnectionTest.php index dc598241c15c..3997a602d8f4 100644 --- a/tests/system/Database/BaseConnectionTest.php +++ b/tests/system/Database/BaseConnectionTest.php @@ -345,4 +345,93 @@ public static function provideEscapeIdentifier(): iterable 'with dots' => ['com.sitedb.web', '"com.sitedb.web"'], ]; } + + public function testConvertTimezoneToOffsetWithOffset(): void + { + $db = new MockConnection($this->options); + + // Offset strings should be returned as-is + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+05:30'); + $this->assertSame('+05:30', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('-08:00'); + $this->assertSame('-08:00', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+00:00'); + $this->assertSame('+00:00', $result); + } + + public function testConvertTimezoneToOffsetWithNamedTimezone(): void + { + $db = new MockConnection($this->options); + + // UTC should always be +00:00 + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('UTC'); + $this->assertSame('+00:00', $result); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('America/New_York'); + $this->assertContains($result, ['-05:00', '-04:00']); // EST/EDT + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Europe/Paris'); + $this->assertContains($result, ['+01:00', '+02:00']); // CET/CEST + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Asia/Tokyo'); + $this->assertSame('+09:00', $result); // JST (no DST) + } + + public function testConvertTimezoneToOffsetWithInvalidTimezone(): void + { + $db = new MockConnection($this->options); + + $result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Invalid/Timezone'); + $this->assertSame('+00:00', $result); + } + + public function testGetSessionTimezoneWithFalse(): void + { + $options = $this->options; + $options['timezone'] = false; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertNull($result); + } + + public function testGetSessionTimezoneWithTrue(): void + { + $options = $this->options; + $options['timezone'] = true; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertSame('+00:00', $result); // UTC = +00:00 + } + + public function testGetSessionTimezoneWithSpecificOffset(): void + { + $options = $this->options; + $options['timezone'] = '+05:30'; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertSame('+05:30', $result); + } + + public function testGetSessionTimezoneWithSpecificNamedTimezone(): void + { + $options = $this->options; + $options['timezone'] = 'America/Chicago'; + $db = new MockConnection($options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertContains($result, ['-06:00', '-05:00']); + } + + public function testGetSessionTimezoneWithoutTimezoneKey(): void + { + $db = new MockConnection($this->options); + + $result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')(); + $this->assertNull($result); + } } diff --git a/tests/system/Database/Live/ConnectTest.php b/tests/system/Database/Live/ConnectTest.php index e41fdbdfc114..bd07f3630a3e 100644 --- a/tests/system/Database/Live/ConnectTest.php +++ b/tests/system/Database/Live/ConnectTest.php @@ -15,6 +15,8 @@ use CodeIgniter\Config\Factories; use CodeIgniter\Database\SQLite3\Connection; +use CodeIgniter\Exceptions\RuntimeException; +use CodeIgniter\I18n\Time; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\DatabaseTestTrait; use Config\Database; @@ -133,4 +135,108 @@ public function testNonSharedInstanceDoesNotAffectSharedInstances(): void $this->assertSame($originalDebugValue, self::getPrivateProperty($secondSharedDb, 'DBDebug')); $this->assertSame(! $originalDebugValue, self::getPrivateProperty($nonSharedDb, 'DBDebug')); } + + public function testTimezoneSetWithSpecificOffset(): void + { + $config = $this->tests; + $config['timezone'] = '+05:30'; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + $this->assertSame('+05:30', $timezone); + } + + public function testTimezoneSetWithNamedTimezone(): void + { + $config = $this->tests; + $config['timezone'] = 'America/New_York'; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + // Named timezones are converted to offsets + // America/New_York is either -05:00 (EST) or -04:00 (EDT) + $this->assertContains($timezone, ['-05:00', '-04:00']); + } + + public function testTimezoneAutoSyncWithAppTimezone(): void + { + $config = $this->tests; + $config['timezone'] = true; + $driver = $config['DBDriver']; + + if (in_array($driver, ['SQLite3', 'SQLSRV'], true)) { + $this->markTestSkipped("Driver {$driver} does not support session timezone"); + } + + $db = Database::connect($config, false); + + $timezone = $this->getDatabaseTimezone($db, $driver); + + $appConfig = config('App'); + $appTimezone = $appConfig->appTimezone ?? 'UTC'; + $expectedOffset = $this->convertTimezoneToOffset($appTimezone); + + $this->assertSame($expectedOffset, $timezone); + } + + /** + * Helper method to get database timezone based on driver + * + * @param mixed $db + */ + private function getDatabaseTimezone($db, string $driver): string + { + switch ($driver) { + case 'MySQLi': + $result = $db->query('SELECT @@session.time_zone as tz')->getRow(); + + return $result->tz; + + case 'Postgre': + $result = $db->query('SHOW TIME ZONE')->getRow(); + + // PostgreSQL returns the timezone name, but we set it as offset + return $result->timezone ?? $result->TimeZone; + + case 'OCI8': + $result = $db->query('SELECT SESSIONTIMEZONE as tz FROM DUAL')->getRow(); + + return $result->tz ?? $result->TZ; + + default: + throw new RuntimeException("Unsupported driver: {$driver}"); + } + } + + /** + * Helper method to convert timezone to offset (mirrors BaseConnection logic) + */ + private function convertTimezoneToOffset(string $timezone): string + { + if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) { + return $timezone; + } + + $time = Time::now($timezone); + $offset = $time->getOffset(); + + $hours = (int) ($offset / 3600); + $minutes = abs((int) (($offset % 3600) / 60)); + + return sprintf('%+03d:%02d', $hours, $minutes); + } } diff --git a/user_guide_src/source/database/configuration.rst b/user_guide_src/source/database/configuration.rst index 3d523bcbf3d9..a70fcbd0a29a 100644 --- a/user_guide_src/source/database/configuration.rst +++ b/user_guide_src/source/database/configuration.rst @@ -191,6 +191,12 @@ Description of Values This can be used since v4.5.0, and you can get the value, e.g., ``$db->dateFormat['datetime']``. Currently, the database drivers do not use these values directly, but :ref:`Model ` uses them. +**timezone** (``MySQLi``, ``Postgre``, and ``OCI8`` only) The database session timezone. + * ``false`` - Don't set session timezone (default, backward compatible) + * ``true`` - Automatically sync with ``App::$appTimezone`` + * ``string`` - Specific timezone offset (e.g., ``'+05:30'``) or named timezone (e.g., ``'America/New_York'``) + Named timezones are automatically converted to offsets for database compatibility. + See :ref:`database-config-timezone` for details. ================ =========================================================================================================== .. _DateTime format: https://www.php.net/manual/en/datetime.format.php @@ -229,3 +235,21 @@ MySQLi driver accepts an array with the following options: * ``ssl_capath`` - Path to a directory containing trusted CA certificates in PEM format * ``ssl_cipher`` - List of *allowed* ciphers to be used for the encryption, separated by colons (``:``) * ``ssl_verify`` - true/false (boolean) - Whether to verify the server certificate or not + +.. _database-config-timezone: + +timezone +-------- + +.. versionadded:: 4.8.0 + +Synchronizes the database session timezone with your application timezone to ensure consistent +timestamps between model operations and database functions like ``NOW()``. + +.. note:: Modern database environments usually have UTC set by default, so this option may not be needed + in most cases. + +Accepts ``false`` (default, don't set), ``true`` (auto-sync with ``App::$appTimezone``), +or a timezone string (e.g., ``'+05:30'`` or ``'America/New_York'``). + +Named timezones are automatically converted to offsets for compatibility. From 31099f414736c53c10ec232e90eb0bde83fecff3 Mon Sep 17 00:00:00 2001 From: michalsn Date: Tue, 3 Feb 2026 07:52:14 +0100 Subject: [PATCH 2/2] add changelog --- user_guide_src/source/changelogs/v4.8.0.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index d093b4f40ddb..3237a4a2116f 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -51,6 +51,8 @@ Forge Others ------ +- Added new ``timezone`` option to connection array in ``Config\Database`` config. This ensures consistent timestamps between model operations and database functions like ``NOW()``. Supported drivers: **MySQLi**, **Postgre**, and **OCI8**. See :ref:`database-config-timezone` for details. + Model =====