Skip to content

Commit 746dfa4

Browse files
authored
feat: add database session timezone configuration (#9925)
1 parent 777faf4 commit 746dfa4

File tree

9 files changed

+327
-16
lines changed

9 files changed

+327
-16
lines changed

app/Config/Database.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class Database extends Config
4949
'datetime' => 'Y-m-d H:i:s',
5050
'time' => 'H:i:s',
5151
],
52+
'timezone' => false,
5253
];
5354

5455
// /**
@@ -98,6 +99,7 @@ class Database extends Config
9899
// 'datetime' => 'Y-m-d H:i:s',
99100
// 'time' => 'H:i:s',
100101
// ],
102+
// 'timezone' => false,
101103
// ];
102104

103105
// /**
@@ -155,6 +157,7 @@ class Database extends Config
155157
// 'datetime' => 'Y-m-d H:i:s',
156158
// 'time' => 'H:i:s',
157159
// ],
160+
// 'timezone' => false,
158161
// ];
159162

160163
/**

system/Database/BaseConnection.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Closure;
1717
use CodeIgniter\Database\Exceptions\DatabaseException;
1818
use CodeIgniter\Events\Events;
19+
use CodeIgniter\I18n\Time;
20+
use Exception;
1921
use stdClass;
2022
use Stringable;
2123
use Throwable;
@@ -156,6 +158,20 @@ abstract class BaseConnection implements ConnectionInterface
156158
*/
157159
protected $DBCollat = '';
158160

161+
/**
162+
* Database session timezone
163+
*
164+
* false = Don't set timezone (default, backward compatible)
165+
* true = Automatically sync with app timezone
166+
* string = Specific timezone (offset or named timezone)
167+
*
168+
* Named timezones (e.g., 'America/New_York') will be automatically
169+
* converted to offsets (e.g., '-05:00') for database compatibility.
170+
*
171+
* @var bool|string
172+
*/
173+
protected $timezone = false;
174+
159175
/**
160176
* Swap Prefix
161177
*
@@ -1915,6 +1931,68 @@ protected function _enableForeignKeyChecks()
19151931
return '';
19161932
}
19171933

1934+
/**
1935+
* Converts a named timezone to an offset string.
1936+
*
1937+
* Converts timezone identifiers (e.g., 'America/New_York') to offset strings
1938+
* (e.g., '-05:00' or '-04:00' depending on DST). This is useful because not all
1939+
* databases have timezone tables loaded, but all support offset notation.
1940+
*
1941+
* @param string $timezone Named timezone (e.g., 'America/New_York', 'UTC', 'Europe/Paris')
1942+
*
1943+
* @return string Offset string (e.g., '+00:00', '-05:00', '+01:00')
1944+
*/
1945+
protected function convertTimezoneToOffset(string $timezone): string
1946+
{
1947+
// If it's already an offset, return as-is
1948+
if (preg_match('/^[+-]\d{2}:\d{2}$/', $timezone)) {
1949+
return $timezone;
1950+
}
1951+
1952+
try {
1953+
$offset = Time::now($timezone)->getOffset();
1954+
1955+
// Convert offset seconds to +-HH:MM format
1956+
$hours = (int) ($offset / 3600);
1957+
$minutes = abs((int) (($offset % 3600) / 60));
1958+
1959+
return sprintf('%+03d:%02d', $hours, $minutes);
1960+
} catch (Exception $e) {
1961+
// If timezone conversion fails, log and return UTC
1962+
log_message('error', "Invalid timezone '{$timezone}'. Falling back to UTC. {$e->getMessage()}.");
1963+
1964+
return '+00:00';
1965+
}
1966+
}
1967+
1968+
/**
1969+
* Gets the timezone string to use for database session.
1970+
*
1971+
* Handles the timezone configuration logic:
1972+
* - false: Don't set timezone (returns null)
1973+
* - true: Auto-sync with app timezone from config
1974+
* - string: Use specific timezone (converts named timezones to offsets)
1975+
*
1976+
* @return string|null The timezone offset string, or null if timezone should not be set
1977+
*/
1978+
protected function getSessionTimezone(): ?string
1979+
{
1980+
if ($this->timezone === false) {
1981+
return null;
1982+
}
1983+
1984+
// Auto-sync with app timezone
1985+
if ($this->timezone === true) {
1986+
$appConfig = config('App');
1987+
$timezone = $appConfig->appTimezone ?? 'UTC';
1988+
} else {
1989+
// Use specific timezone from config
1990+
$timezone = $this->timezone;
1991+
}
1992+
1993+
return $this->convertTimezoneToOffset($timezone);
1994+
}
1995+
19181996
/**
19191997
* Accessor for properties if they exist.
19201998
*

system/Database/MySQLi/Connection.php

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,27 +123,38 @@ public function connect(bool $persistent = false)
123123
$this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1);
124124
}
125125

126+
// Build init command for strictOn and timezone
127+
$initCommands = [];
128+
126129
if ($this->strictOn !== null) {
127130
if ($this->strictOn) {
128-
$this->mysqli->options(
129-
MYSQLI_INIT_COMMAND,
130-
"SET SESSION sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')",
131-
);
131+
$initCommands[] = "sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')";
132132
} else {
133-
$this->mysqli->options(
134-
MYSQLI_INIT_COMMAND,
135-
"SET SESSION sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
136-
@@sql_mode,
137-
'STRICT_ALL_TABLES,', ''),
138-
',STRICT_ALL_TABLES', ''),
139-
'STRICT_ALL_TABLES', ''),
140-
'STRICT_TRANS_TABLES,', ''),
141-
',STRICT_TRANS_TABLES', ''),
142-
'STRICT_TRANS_TABLES', '')",
143-
);
133+
$initCommands[] = "sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
134+
@@sql_mode,
135+
'STRICT_ALL_TABLES,', ''),
136+
',STRICT_ALL_TABLES', ''),
137+
'STRICT_ALL_TABLES', ''),
138+
'STRICT_TRANS_TABLES,', ''),
139+
',STRICT_TRANS_TABLES', ''),
140+
'STRICT_TRANS_TABLES', '')";
144141
}
145142
}
146143

144+
// Set session timezone if configured
145+
$timezoneOffset = $this->getSessionTimezone();
146+
if ($timezoneOffset !== null) {
147+
$initCommands[] = "time_zone = '{$timezoneOffset}'";
148+
}
149+
150+
// Set init command if we have any commands
151+
if ($initCommands !== []) {
152+
$this->mysqli->options(
153+
MYSQLI_INIT_COMMAND,
154+
'SET SESSION ' . implode(', ', $initCommands),
155+
);
156+
}
157+
147158
if (is_array($this->encrypt)) {
148159
$ssl = [];
149160

system/Database/OCI8/Connection.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,19 @@ public function connect(bool $persistent = false)
145145

146146
$func = $persistent ? 'oci_pconnect' : 'oci_connect';
147147

148-
return ($this->charset === '')
148+
$this->connID = ($this->charset === '')
149149
? $func($this->username, $this->password, $this->DSN)
150150
: $func($this->username, $this->password, $this->DSN, $this->charset);
151+
152+
// Set session timezone if configured and connection is successful
153+
if ($this->connID !== false) {
154+
$timezoneOffset = $this->getSessionTimezone();
155+
if ($timezoneOffset !== null) {
156+
$this->simpleQuery("ALTER SESSION SET TIME_ZONE = '{$timezoneOffset}'");
157+
}
158+
}
159+
160+
return $this->connID;
151161
}
152162

153163
/**

system/Database/Postgre/Connection.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ public function connect(bool $persistent = false)
9797

9898
throw new DatabaseException($error);
9999
}
100+
101+
// Set session timezone if configured
102+
$timezoneOffset = $this->getSessionTimezone();
103+
if ($timezoneOffset !== null) {
104+
$this->simpleQuery("SET TIME ZONE '{$timezoneOffset}'");
105+
}
100106
}
101107

102108
return $this->connID;

tests/system/Database/BaseConnectionTest.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,94 @@ public static function provideEscapeIdentifier(): iterable
345345
'with dots' => ['com.sitedb.web', '"com.sitedb.web"'],
346346
];
347347
}
348+
349+
public function testConvertTimezoneToOffsetWithOffset(): void
350+
{
351+
$db = new MockConnection($this->options);
352+
353+
// Offset strings should be returned as-is
354+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+05:30');
355+
$this->assertSame('+05:30', $result);
356+
357+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('-08:00');
358+
$this->assertSame('-08:00', $result);
359+
360+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('+00:00');
361+
$this->assertSame('+00:00', $result);
362+
}
363+
364+
public function testConvertTimezoneToOffsetWithNamedTimezone(): void
365+
{
366+
$db = new MockConnection($this->options);
367+
368+
// UTC should always be +00:00
369+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('UTC');
370+
$this->assertSame('+00:00', $result);
371+
372+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('America/New_York');
373+
$this->assertContains($result, ['-05:00', '-04:00']); // EST/EDT
374+
375+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Europe/Paris');
376+
$this->assertContains($result, ['+01:00', '+02:00']); // CET/CEST
377+
378+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Asia/Tokyo');
379+
$this->assertSame('+09:00', $result); // JST (no DST)
380+
}
381+
382+
public function testConvertTimezoneToOffsetWithInvalidTimezone(): void
383+
{
384+
$db = new MockConnection($this->options);
385+
386+
$result = $this->getPrivateMethodInvoker($db, 'convertTimezoneToOffset')('Invalid/Timezone');
387+
$this->assertSame('+00:00', $result);
388+
$this->assertLogged('error', "Invalid timezone 'Invalid/Timezone'. Falling back to UTC. DateTimeZone::__construct(): Unknown or bad timezone (Invalid/Timezone).");
389+
}
390+
391+
public function testGetSessionTimezoneWithFalse(): void
392+
{
393+
$options = $this->options;
394+
$options['timezone'] = false;
395+
$db = new MockConnection($options);
396+
397+
$result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')();
398+
$this->assertNull($result);
399+
}
400+
401+
public function testGetSessionTimezoneWithTrue(): void
402+
{
403+
$options = $this->options;
404+
$options['timezone'] = true;
405+
$db = new MockConnection($options);
406+
407+
$result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')();
408+
$this->assertSame('+00:00', $result); // UTC = +00:00
409+
}
410+
411+
public function testGetSessionTimezoneWithSpecificOffset(): void
412+
{
413+
$options = $this->options;
414+
$options['timezone'] = '+05:30';
415+
$db = new MockConnection($options);
416+
417+
$result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')();
418+
$this->assertSame('+05:30', $result);
419+
}
420+
421+
public function testGetSessionTimezoneWithSpecificNamedTimezone(): void
422+
{
423+
$options = $this->options;
424+
$options['timezone'] = 'America/Chicago';
425+
$db = new MockConnection($options);
426+
427+
$result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')();
428+
$this->assertContains($result, ['-06:00', '-05:00']);
429+
}
430+
431+
public function testGetSessionTimezoneWithoutTimezoneKey(): void
432+
{
433+
$db = new MockConnection($this->options);
434+
435+
$result = $this->getPrivateMethodInvoker($db, 'getSessionTimezone')();
436+
$this->assertNull($result);
437+
}
348438
}

0 commit comments

Comments
 (0)