Merge pull request #59360 from nextcloud/fix/sql-tz

fix(db): set session timezone to UTC
This commit is contained in:
Ferdinand Thiessen 2026-04-15 17:46:14 +02:00 committed by GitHub
commit 64c1d17c7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 245 additions and 40 deletions

View file

@ -129,6 +129,9 @@ jobs:
- name: PHPUnit
run: composer run test:db -- --log-junit junit.xml ${{ matrix.coverage && '--coverage-clover ./clover.db.xml' || '' }}
env:
DB_ROOT_USER: root
DB_ROOT_PASS: rootpassword
- name: Upload db code coverage
if: ${{ !cancelled() && matrix.coverage }}

View file

@ -129,6 +129,9 @@ jobs:
- name: PHPUnit
run: composer run test:db -- --log-junit junit.xml ${{ matrix.coverage && '--coverage-clover ./clover.db.xml' || '' }}
env:
DB_ROOT_USER: root
DB_ROOT_PASS: rootpassword
- name: Upload db code coverage
if: ${{ !cancelled() && matrix.coverage }}

View file

@ -36,6 +36,18 @@ set -e
_XDEBUG_CONFIG=$XDEBUG_CONFIG
unset XDEBUG_CONFIG
# Get the IP address of a docker container
#
# @param $1 - The container id
function docker_get_ip {
_docker_version=$(docker --version | grep -Po "(?<=Docker version )\d+")
if [ "$_docker_version" -ge 29 ]; then
docker inspect --format="{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" "$1"
else
docker inspect --format="{{.NetworkSettings.IPAddress}}" "$1"
fi
}
function print_syntax {
echo -e "Syntax: ./autotest.sh [dbconfigname] [testfile]\n" >&2
echo -e "\t\"dbconfigname\" can be one of: $DBCONFIGS" >&2
@ -201,19 +213,15 @@ function execute_tests {
-e MYSQL_PASSWORD=owncloud \
-e MYSQL_DATABASE="$DATABASENAME" \
-d mysql)
DATABASEHOST=$(docker inspect --format="{{.NetworkSettings.IPAddress}}" "$DOCKER_CONTAINER_ID")
DATABASEHOST=$(docker_get_ip "$DOCKER_CONTAINER_ID")
else
if [ -z "$DRONE" ] ; then # no need to drop the DB when we are on CI
if [ "mysql" != "$(mysql --version | grep -o mysql)" ] ; then
echo "Your mysql binary is not provided by mysql"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
else
DATABASEHOST=mysql
if [ "mysql" != "$(mysql --version | grep -o mysql)" ] ; then
echo "Your mysql binary is not provided by mysql"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
fi
echo "Waiting for MySQL initialisation ..."
if ! apps/files_external/tests/env/wait-for-connection $DATABASEHOST 3306 300; then
@ -235,19 +243,15 @@ function execute_tests {
--innodb_file_format=barracuda \
--innodb_file_per_table=true)
DATABASEHOST=$(docker inspect --format="{{.NetworkSettings.IPAddress}}" "$DOCKER_CONTAINER_ID")
DATABASEHOST=$(docker_get_ip "$DOCKER_CONTAINER_ID")
else
if [ -z "$DRONE" ] ; then # no need to drop the DB when we are on CI
if [ "mysql" != "$(mysql --version | grep -o mysql)" ] ; then
echo "Your mysql binary is not provided by mysql"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
else
DATABASEHOST=mysqlmb4
if [ "mysql" != "$(mysql --version | grep -o mysql)" ] ; then
echo "Your mysql binary is not provided by mysql"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
fi
echo "Waiting for MySQL(utf8mb4) initialisation ..."
@ -273,7 +277,7 @@ function execute_tests {
-e MYSQL_PASSWORD=owncloud \
-e MYSQL_DATABASE="$DATABASENAME" \
-d mariadb)
DATABASEHOST=$(docker inspect --format="{{.NetworkSettings.IPAddress}}" "$DOCKER_CONTAINER_ID")
DATABASEHOST=$(docker_get_ip "$DOCKER_CONTAINER_ID")
echo "Waiting for MariaDB initialisation ..."
if ! apps/files_external/tests/env/wait-for-connection $DATABASEHOST 3306 300; then
@ -284,16 +288,12 @@ function execute_tests {
echo "MariaDB is up."
else
if [ -z "$DRONE" ] ; then # no need to drop the DB when we are on CI
if [ "MariaDB" != "$(mysql --version | grep -o MariaDB)" ] ; then
echo "Your mysql binary is not provided by MariaDB"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
else
DATABASEHOST=mariadb
if [ "MariaDB" != "$(mysql --version | grep -o MariaDB)" ] ; then
echo "Your mysql binary is not provided by MariaDB"
echo "To use the docker container set the USEDOCKER environment variable"
exit -1
fi
mysql -u "$DATABASEUSER" -powncloud -e "DROP DATABASE IF EXISTS $DATABASENAME" -h $DATABASEHOST || true
fi
echo "Waiting for MariaDB initialisation ..."
@ -309,7 +309,7 @@ function execute_tests {
if [ ! -z "$USEDOCKER" ] ; then
echo "Fire up the postgres docker"
DOCKER_CONTAINER_ID=$(docker run -e POSTGRES_DB="$DATABASENAME" -e POSTGRES_USER="$DATABASEUSER" -e POSTGRES_PASSWORD=owncloud -d postgres)
DATABASEHOST=$(docker inspect --format="{{.NetworkSettings.IPAddress}}" "$DOCKER_CONTAINER_ID")
DATABASEHOST=$(docker_get_ip "$DOCKER_CONTAINER_ID")
echo "Waiting for Postgres initialisation ..."
@ -320,9 +320,6 @@ function execute_tests {
echo "Postgres is up."
else
if [ ! -z "$DRONE" ] ; then
DATABASEHOST="postgres-$POSTGRES"
fi
echo "Waiting for Postgres to be available ..."
if ! apps/files_external/tests/env/wait-for-connection $DATABASEHOST 5432 60; then
echo "[ERROR] Waited 60 seconds for $DATABASEHOST, no response" >&2
@ -331,15 +328,13 @@ function execute_tests {
echo "Give it 10 additional seconds ..."
sleep 10
if [ -z "$DRONE" ] ; then # no need to drop the DB when we are on CI
dropdb -U "$DATABASEUSER" "$DATABASENAME" || true
fi
dropdb -U "$DATABASEUSER" "$DATABASENAME" || true
fi
fi
if [ "$DB" == "oci" ] ; then
echo "Fire up the oracle docker"
DOCKER_CONTAINER_ID=$(docker run -d deepdiver/docker-oracle-xe-11g)
DATABASEHOST=$(docker inspect --format="{{.NetworkSettings.IPAddress}}" "$DOCKER_CONTAINER_ID")
DATABASEHOST=$(docker_get_ip "$DOCKER_CONTAINER_ID")
echo "Waiting for Oracle initialization ... "
@ -397,7 +392,7 @@ function execute_tests {
fi
echo "$PHPUNIT" --fail-on-warning --fail-on-risky --display-warnings --display-deprecations --display-phpunit-deprecations --colors=always --configuration phpunit-autotest.xml $GROUP $COVER --log-junit "autotest-results-$DB.xml" "$2" "$3"
"$PHPUNIT" --fail-on-warning --fail-on-risky --display-warnings --display-deprecations --display-phpunit-deprecations --colors=always --configuration phpunit-autotest.xml $GROUP $COVER --log-junit "autotest-results-$DB.xml" "$2" "$3"
DB_ROOT_PASSWORD=owncloud DB_ROOT_USER="root" "$PHPUNIT" --fail-on-warning --fail-on-risky --display-warnings --display-deprecations --display-phpunit-deprecations --colors=always --configuration phpunit-autotest.xml $GROUP $COVER --log-junit "autotest-results-$DB.xml" "$2" "$3"
RESULT=$?
if [ "$PRIMARY_STORAGE_CONFIG" == "swift" ] ; then

View file

@ -1611,6 +1611,8 @@ return array(
'OC\\DB\\ConnectionFactory' => $baseDir . '/lib/private/DB/ConnectionFactory.php',
'OC\\DB\\DbDataCollector' => $baseDir . '/lib/private/DB/DbDataCollector.php',
'OC\\DB\\Exceptions\\DbalException' => $baseDir . '/lib/private/DB/Exceptions/DbalException.php',
'OC\\DB\\Middleware\\UtcTimezoneMiddleware' => $baseDir . '/lib/private/DB/Middleware/UtcTimezoneMiddleware.php',
'OC\\DB\\Middleware\\UtcTimezoneMiddlewareDriver' => $baseDir . '/lib/private/DB/Middleware/UtcTimezoneMiddlewareDriver.php',
'OC\\DB\\MigrationException' => $baseDir . '/lib/private/DB/MigrationException.php',
'OC\\DB\\MigrationService' => $baseDir . '/lib/private/DB/MigrationService.php',
'OC\\DB\\Migrator' => $baseDir . '/lib/private/DB/Migrator.php',

View file

@ -1652,6 +1652,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\DB\\ConnectionFactory' => __DIR__ . '/../../..' . '/lib/private/DB/ConnectionFactory.php',
'OC\\DB\\DbDataCollector' => __DIR__ . '/../../..' . '/lib/private/DB/DbDataCollector.php',
'OC\\DB\\Exceptions\\DbalException' => __DIR__ . '/../../..' . '/lib/private/DB/Exceptions/DbalException.php',
'OC\\DB\\Middleware\\UtcTimezoneMiddleware' => __DIR__ . '/../../..' . '/lib/private/DB/Middleware/UtcTimezoneMiddleware.php',
'OC\\DB\\Middleware\\UtcTimezoneMiddlewareDriver' => __DIR__ . '/../../..' . '/lib/private/DB/Middleware/UtcTimezoneMiddlewareDriver.php',
'OC\\DB\\MigrationException' => __DIR__ . '/../../..' . '/lib/private/DB/MigrationException.php',
'OC\\DB\\MigrationService' => __DIR__ . '/../../..' . '/lib/private/DB/MigrationService.php',
'OC\\DB\\Migrator' => __DIR__ . '/../../..' . '/lib/private/DB/Migrator.php',

View file

@ -11,6 +11,7 @@ use Doctrine\Common\EventManager;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Event\Listeners\OracleSessionInit;
use OC\DB\Middleware\UtcTimezoneMiddleware;
use OC\DB\QueryBuilder\Sharded\AutoIncrementHandler;
use OC\DB\QueryBuilder\Sharded\ShardConnectionManager;
use OC\SystemConfig;
@ -143,10 +144,14 @@ class ConnectionFactory {
$eventManager->addEventSubscriber(new SQLiteSessionInit(true, $journalMode));
break;
}
$configuration = new Configuration();
$configuration->setMiddlewares([
new UtcTimezoneMiddleware(),
]);
/** @var Connection $connection */
$connection = DriverManager::getConnection(
$connectionParams,
new Configuration(),
$configuration,
$eventManager
);
return $connection;

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\DB\Middleware;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Middleware;
/**
* Custom doctrine middleware to ensure that the session timezone is set to UTC.
*
* @since 34.0.0
*/
final class UtcTimezoneMiddleware implements Middleware {
#[\Override]
public function wrap(Driver $driver): Driver {
return new UtcTimezoneMiddlewareDriver($driver);
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\DB\Middleware;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
/**
* Driver middleware to ensure the session timezone is set to UTC.
* This ensures consistent timezone handling, regardless of server configuration,
* similar to how we set the PHP timezone to UTC for Nextcloud.
*
* @since 34.0.0
*/
final class UtcTimezoneMiddlewareDriver extends AbstractDriverMiddleware {
#[\Override]
public function connect(array $params) {
$connection = parent::connect($params);
$platform = $this->getDatabasePlatform();
if (($platform instanceof MariaDBPlatform) || ($platform instanceof MySQLPlatform)) {
$connection->exec("SET time_zone = '+00:00'");
} elseif ($platform instanceof PostgreSQLPlatform) {
$connection->exec("SET TIME ZONE 'UTC'");
} elseif ($platform instanceof OraclePlatform) {
$connection->exec("ALTER SESSION SET TIME_ZONE='Etc/UTC'");
}
return $connection;
}
}

View file

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\DB\Middleware;
use OC\DB\Connection;
use OC\DB\ConnectionFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\Server;
use PHPUnit\Framework\Attributes\Group;
use Test\TestCase;
/**
* We cannot test the actual driver here,
* but we can at least test that it does what we want.
*/
#[Group('DB')]
final class UtcTimezoneMiddlewareDriverTest extends TestCase {
private ?Connection $connection = null;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->connection = $this->getRootDbConnection();
if ($this->connection === null) {
$this->markTestSkipped('No root database credentials provided (DB_ROOT_USER, DB_ROOT_PASSWORD), cannot run test');
return;
}
$provider = $this->connection->getDatabaseProvider();
if ($provider === IDBConnection::PLATFORM_MARIADB || $provider === IDBConnection::PLATFORM_MYSQL) {
$this->connection->executeStatement("SET GLOBAL time_zone = 'America/New_York'");
} else {
$this->markTestSkipped('This test only works with MySQL/MariaDB');
}
}
#[\Override]
protected function tearDown(): void {
if ($this->connection !== null) {
$provider = $this->connection->getDatabaseProvider();
if ($provider === IDBConnection::PLATFORM_MARIADB || $provider === IDBConnection::PLATFORM_MYSQL) {
$this->connection->executeStatement("SET GLOBAL time_zone = 'SYSTEM'");
}
$this->connection->close();
}
parent::tearDown();
}
public function testSqlNowIsInUtc() {
$connection = $this->getDbConnection();
$result = $connection->executeQuery('SELECT NOW()');
$data = $result->fetchOne();
$connection->close();
self::assertIsString($data, 'Expected a string from the database');
$expected = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$received = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $data, new \DateTimeZone('UTC'));
$diff = abs($received->getTimestamp() - $expected->getTimestamp());
self::assertLessThan(15 * 60, $diff); // allow up to 15 minutes of difference, to account for slow test environments and time sync issues.
}
/**
* Get a new database connection.
* This is needed because the setup is changing the global timezone setting,
* but its only applied for new connections.
*/
private function getDbConnection(array $overrides = []): Connection {
$config = Server::get(IConfig::class);
$cf = Server::get(ConnectionFactory::class);
return $cf->getConnection(
$config->getSystemValue('dbtype'),
[
'host' => $config->getSystemValue('dbhost'),
'user' => $config->getSystemValue('dbuser'),
'password' => $config->getSystemValue('dbpassword'),
'tablePrefix' => $config->getSystemValue('dbtableprefix'),
'dbname' => $config->getSystemValue('dbname'),
...$overrides,
],
);
}
/**
* Get the database connection as root user,
* so that we can change the global timezone setting.
*/
private function getRootDbConnection(): ?Connection {
$rootUser = getenv('DB_ROOT_USER') ?: '';
$rootPassword = getenv('DB_ROOT_PASSWORD') ?: '';
if ($rootPassword === '' || $rootUser === '') {
return null;
}
return $this->getDbConnection([
'user' => $rootUser,
'password' => $rootPassword,
]);
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\DB\Middleware;
use OC\DB\Middleware\UtcTimezoneMiddleware;
use OC\DB\Middleware\UtcTimezoneMiddlewareDriver;
use Test\TestCase;
final class UtcTimezoneMiddlewareTest extends TestCase {
public function testWrap(): void {
$driver = $this->createMock(\Doctrine\DBAL\Driver::class);
$middleware = new UtcTimezoneMiddleware();
$wrappedDriver = $middleware->wrap($driver);
$this->assertInstanceOf(UtcTimezoneMiddlewareDriver::class, $wrappedDriver);
}
}