Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Config/Database.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class Database extends Config
'datetime' => 'Y-m-d H:i:s',
'time' => 'H:i:s',
],
'timezone' => false,
];

// /**
Expand Down Expand Up @@ -98,6 +99,7 @@ class Database extends Config
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// 'timezone' => false,
// ];

// /**
Expand Down Expand Up @@ -155,6 +157,7 @@ class Database extends Config
// 'datetime' => 'Y-m-d H:i:s',
// 'time' => 'H:i:s',
// ],
// 'timezone' => false,
// ];

/**
Expand Down
78 changes: 78 additions & 0 deletions system/Database/BaseConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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.
*
Expand Down
41 changes: 26 additions & 15 deletions system/Database/MySQLi/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand Down
12 changes: 11 additions & 1 deletion system/Database/OCI8/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions system/Database/Postgre/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
89 changes: 89 additions & 0 deletions tests/system/Database/BaseConnectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading
Loading