From 1d625cd1cbc1fb6226855bc856f3578c083dd967 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Christian=20E=C3=9Fl?= <indy.essl@gmail.com>
Date: Sat, 4 Mar 2023 21:23:57 +0100
Subject: [PATCH] [FEATURE] Introduce Doctrine DBAL v3 driver middlewares
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Since v3, Doctrine DBAL supports adding custom driver
middlewares. These middlewares act as a decorator around
the actual `Driver` component. Subsequently, the `Connection`,
`Statement` and `Result` components can be decorated as well.

A new setting is introduced to register custom driver
middlewares that will automatically be added to a newly
established Doctrine database connection.

Resolves: #100089
Releases: main
Change-Id: I4cf8e0252c3241f49ce1b9adb68cb85d978e164b
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/78029
Tested-by: core-ci <typo3@b13.com>
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../Classes/Log/DoctrineSqlLogger.php         | 30 +++++----
 .../Log/DoctrineSqlLoggingMiddleware.php      | 48 ++++++++++++++
 .../Classes/Log/LoggingConnection.php         | 63 +++++++++++++++++++
 .../adminpanel/Classes/Log/LoggingDriver.php  | 43 +++++++++++++
 .../Classes/Log/LoggingStatement.php          | 61 ++++++++++++++++++
 .../Classes/Middleware/SqlLogging.php         | 10 ++-
 .../Modules/Debug/QueryInformation.php        | 16 +++--
 typo3/sysext/adminpanel/ext_localconf.php     |  6 ++
 .../core/Classes/Database/ConnectionPool.php  | 26 +++++++-
 ...troduceDoctrineDBALv3drivermiddlewares.rst | 38 +++++++++++
 10 files changed, 321 insertions(+), 20 deletions(-)
 create mode 100644 typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLoggingMiddleware.php
 create mode 100644 typo3/sysext/adminpanel/Classes/Log/LoggingConnection.php
 create mode 100644 typo3/sysext/adminpanel/Classes/Log/LoggingDriver.php
 create mode 100644 typo3/sysext/adminpanel/Classes/Log/LoggingStatement.php
 create mode 100644 typo3/sysext/core/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst

diff --git a/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLogger.php b/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLogger.php
index 63406d70e1b7..20cba821f686 100644
--- a/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLogger.php
+++ b/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLogger.php
@@ -17,25 +17,31 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Adminpanel\Log;
 
-use Doctrine\DBAL\Logging\SQLLogger;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
 use TYPO3\CMS\Adminpanel\Utility\MemoryUtility;
 
 /**
  * Doctrine SQL Logger implementation for recording queries for the admin panel
+ *
+ * @internal
  */
-class DoctrineSqlLogger implements SQLLogger, LoggerAwareInterface
+class DoctrineSqlLogger implements LoggerAwareInterface
 {
     use LoggerAwareTrait;
 
     /** Executed SQL queries. */
     protected array $queries = [];
     /** If Debug Stack is enabled (log queries) or not. */
-    protected bool $enabled = true;
+    protected bool $enabled = false;
     protected float $start;
     protected int $currentQuery = 0;
 
+    public function enable(): void
+    {
+        $this->enabled = true;
+    }
+
     public function startQuery($sql, array $params = null, array $types = null): void
     {
         if ($this->enabled && MemoryUtility::isMemoryConsumptionTooHigh()) {
@@ -43,18 +49,18 @@ class DoctrineSqlLogger implements SQLLogger, LoggerAwareInterface
             $this->logger->warning('SQL Logging consumed too much memory, aborted. Not all queries have been recorded.');
         }
         if ($this->enabled) {
+            $visibleBacktraceLength = 4;
+            $removeFromBacktraceLength = 4;
+
             $this->start = microtime(true);
-            $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7);
-            // remove this method
-            array_shift($backtrace);
-            // remove doctrine execute query
-            array_shift($backtrace);
-            // remove queryBuilder execute
-            array_shift($backtrace);
+            $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, $visibleBacktraceLength + $removeFromBacktraceLength);
+            // remove internal doctrine and logging methods from the visible backtrace
+            array_splice($backtrace, 0, $removeFromBacktraceLength);
+
             $this->queries[++$this->currentQuery] = [
                 'sql' => $sql,
-                'params' => $params,
-                'types' => $types,
+                'params' => $params ?? [],
+                'types' => $types ?? [],
                 'executionMS' => 0,
                 'backtrace' => $backtrace,
             ];
diff --git a/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLoggingMiddleware.php b/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLoggingMiddleware.php
new file mode 100644
index 000000000000..a38af566c537
--- /dev/null
+++ b/typo3/sysext/adminpanel/Classes/Log/DoctrineSqlLoggingMiddleware.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Adminpanel\Log;
+
+use Doctrine\DBAL\Driver as DriverInterface;
+use Doctrine\DBAL\Driver\Middleware as MiddlewareInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Wrapper around the Doctrine SQL Driver for logging sql queries
+ *
+ * @internal
+ */
+class DoctrineSqlLoggingMiddleware implements MiddlewareInterface
+{
+    protected DoctrineSqlLogger $logger;
+
+    public function wrap(DriverInterface $driver): DriverInterface
+    {
+        $this->logger = GeneralUtility::makeInstance(DoctrineSqlLogger::class);
+        return new LoggingDriver($driver, $this->logger);
+    }
+
+    public function enable(): void
+    {
+        $this->logger->enable();
+    }
+
+    public function getQueries(): array
+    {
+        return $this->logger->getQueries();
+    }
+}
diff --git a/typo3/sysext/adminpanel/Classes/Log/LoggingConnection.php b/typo3/sysext/adminpanel/Classes/Log/LoggingConnection.php
new file mode 100644
index 000000000000..f5e2df1a679f
--- /dev/null
+++ b/typo3/sysext/adminpanel/Classes/Log/LoggingConnection.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Adminpanel\Log;
+
+use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
+use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
+use Doctrine\DBAL\Driver\Result;
+use Doctrine\DBAL\Driver\Statement as DriverStatement;
+
+/**
+ * Part of the Doctrine SQL Logging Driver Adapter
+ *
+ * @internal
+ */
+final class LoggingConnection extends AbstractConnectionMiddleware
+{
+    private DoctrineSqlLogger $logger;
+
+    public function __construct(ConnectionInterface $connection, DoctrineSqlLogger $logger)
+    {
+        parent::__construct($connection);
+
+        $this->logger = $logger;
+    }
+
+    public function prepare(string $sql): DriverStatement
+    {
+        return new LoggingStatement(parent::prepare($sql), $this->logger, $sql);
+    }
+
+    public function query(string $sql): Result
+    {
+        $this->logger->startQuery($sql);
+        $query = parent::query($sql);
+        $this->logger->stopQuery();
+
+        return $query;
+    }
+
+    public function exec(string $sql): int
+    {
+        $this->logger->startQuery($sql);
+        $query = parent::exec($sql);
+        $this->logger->stopQuery();
+
+        return $query;
+    }
+}
diff --git a/typo3/sysext/adminpanel/Classes/Log/LoggingDriver.php b/typo3/sysext/adminpanel/Classes/Log/LoggingDriver.php
new file mode 100644
index 000000000000..9ae883b34159
--- /dev/null
+++ b/typo3/sysext/adminpanel/Classes/Log/LoggingDriver.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Adminpanel\Log;
+
+use Doctrine\DBAL\Driver as DriverInterface;
+use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
+
+/**
+ * Part of the Doctrine SQL Logging Driver Adapter
+ *
+ * @internal
+ */
+final class LoggingDriver extends AbstractDriverMiddleware
+{
+    private DoctrineSqlLogger $logger;
+
+    public function __construct(DriverInterface $driver, DoctrineSqlLogger $logger)
+    {
+        parent::__construct($driver);
+
+        $this->logger = $logger;
+    }
+
+    public function connect(array $params)
+    {
+        return new LoggingConnection(parent::connect($params), $this->logger);
+    }
+}
diff --git a/typo3/sysext/adminpanel/Classes/Log/LoggingStatement.php b/typo3/sysext/adminpanel/Classes/Log/LoggingStatement.php
new file mode 100644
index 000000000000..0be233c17128
--- /dev/null
+++ b/typo3/sysext/adminpanel/Classes/Log/LoggingStatement.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+namespace TYPO3\CMS\Adminpanel\Log;
+
+use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
+use Doctrine\DBAL\Driver\Result as ResultInterface;
+use Doctrine\DBAL\Driver\Statement as StatementInterface;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Part of the Doctrine SQL Logging Driver Adapter
+ *
+ * @internal
+ */
+final class LoggingStatement extends AbstractStatementMiddleware
+{
+    private DoctrineSqlLogger $logger;
+    private string $sql;
+    private array $params = [];
+    private array $types = [];
+
+    public function __construct(StatementInterface $statement, DoctrineSqlLogger $logger, string $sql)
+    {
+        parent::__construct($statement);
+
+        $this->logger = $logger;
+        $this->sql    = $sql;
+    }
+
+    public function bindValue($param, $value, $type = ParameterType::STRING)
+    {
+        $this->params[$param] = $value;
+        $this->types[$param]  = $type;
+
+        return parent::bindValue($param, $value, $type);
+    }
+
+    public function execute($params = null): ResultInterface
+    {
+        $this->logger->startQuery($this->sql, $params ?? $this->params, $this->types);
+        $result = parent::execute($params);
+        $this->logger->stopQuery();
+
+        return $result;
+    }
+}
diff --git a/typo3/sysext/adminpanel/Classes/Middleware/SqlLogging.php b/typo3/sysext/adminpanel/Classes/Middleware/SqlLogging.php
index 7c9146fe2bfa..65623b82bdbd 100644
--- a/typo3/sysext/adminpanel/Classes/Middleware/SqlLogging.php
+++ b/typo3/sysext/adminpanel/Classes/Middleware/SqlLogging.php
@@ -21,10 +21,9 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\MiddlewareInterface;
 use Psr\Http\Server\RequestHandlerInterface;
-use TYPO3\CMS\Adminpanel\Log\DoctrineSqlLogger;
+use TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware;
 use TYPO3\CMS\Adminpanel\Utility\StateUtility;
 use TYPO3\CMS\Core\Database\ConnectionPool;
-use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
  * Enable sql logging for the admin panel
@@ -45,7 +44,12 @@ class SqlLogging implements MiddlewareInterface
     {
         if (StateUtility::isActivatedForUser() && StateUtility::isOpen()) {
             $connection = $this->connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
-            $connection->getConfiguration()->setSQLLogger(GeneralUtility::makeInstance(DoctrineSqlLogger::class));
+            foreach ($connection->getConfiguration()->getMiddlewares() as $middleware) {
+                if ($middleware instanceof DoctrineSqlLoggingMiddleware) {
+                    $middleware->enable();
+                    break;
+                }
+            }
         }
         return $handler->handle($request);
     }
diff --git a/typo3/sysext/adminpanel/Classes/Modules/Debug/QueryInformation.php b/typo3/sysext/adminpanel/Classes/Modules/Debug/QueryInformation.php
index e435c513b9f6..f7ec173bcaf3 100644
--- a/typo3/sysext/adminpanel/Classes/Modules/Debug/QueryInformation.php
+++ b/typo3/sysext/adminpanel/Classes/Modules/Debug/QueryInformation.php
@@ -18,7 +18,7 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Adminpanel\Modules\Debug;
 
 use Psr\Http\Message\ServerRequestInterface;
-use TYPO3\CMS\Adminpanel\Log\DoctrineSqlLogger;
+use TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware;
 use TYPO3\CMS\Adminpanel\ModuleApi\AbstractSubModule;
 use TYPO3\CMS\Adminpanel\ModuleApi\DataProviderInterface;
 use TYPO3\CMS\Adminpanel\ModuleApi\ModuleData;
@@ -56,10 +56,18 @@ class QueryInformation extends AbstractSubModule implements DataProviderInterfac
     {
         $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
         $connection = $connectionPool->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
-        $logger = $connection->getConfiguration()->getSQLLogger();
+
+        $loggingMiddleware = null;
+        foreach ($connection->getConfiguration()->getMiddlewares() as $middleware) {
+            if ($middleware instanceof DoctrineSqlLoggingMiddleware) {
+                $loggingMiddleware = $middleware;
+                break;
+            }
+        }
+
         $data = [];
-        if ($logger instanceof DoctrineSqlLogger) {
-            $queries = $logger->getQueries();
+        if ($loggingMiddleware instanceof DoctrineSqlLoggingMiddleware) {
+            $queries = $loggingMiddleware->getQueries();
             $data['totalQueries'] = count($queries);
             $data['queries'] = $this->groupQueries($queries);
             $data['totalTime'] = array_sum(array_column($queries, 'executionMS')) * 1000;
diff --git a/typo3/sysext/adminpanel/ext_localconf.php b/typo3/sysext/adminpanel/ext_localconf.php
index 312d03b582a5..1b6f01160dd3 100644
--- a/typo3/sysext/adminpanel/ext_localconf.php
+++ b/typo3/sysext/adminpanel/ext_localconf.php
@@ -87,3 +87,9 @@ $GLOBALS['TYPO3_CONF_VARS']['LOG']['writerConfiguration'][LogLevel::DEBUG][InMem
 if (!is_array($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['adminpanel_requestcache'] ?? null)) {
     $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['adminpanel_requestcache'] = [];
 }
+
+if (!is_array($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares'] ?? null)) {
+    $GLOBALS['TYPO3_CONF_VARS']['SYS']['DB']['Connections']['Default']['driverMiddlewares'] = [];
+}
+
+$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares']['adminpanel_loggingmiddleware'] = \TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware::class;
diff --git a/typo3/sysext/core/Classes/Database/ConnectionPool.php b/typo3/sysext/core/Classes/Database/ConnectionPool.php
index ac1a2f88f808..7b7f85e28776 100644
--- a/typo3/sysext/core/Classes/Database/ConnectionPool.php
+++ b/typo3/sysext/core/Classes/Database/ConnectionPool.php
@@ -17,6 +17,8 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Core\Database;
 
+use Doctrine\DBAL\Configuration;
+use Doctrine\DBAL\Driver\Middleware as DriverMiddleware;
 use Doctrine\DBAL\DriverManager;
 use Doctrine\DBAL\Events;
 use Doctrine\DBAL\Types\Type;
@@ -174,6 +176,8 @@ class ConnectionPool
 
     /**
      * Map custom driver class for certain driver
+     *
+     * @internal
      */
     protected function mapCustomDriver(array $connectionParams): array
     {
@@ -185,6 +189,24 @@ class ConnectionPool
         return $connectionParams;
     }
 
+    /**
+     * Return any doctrine driver middlewares, that may have been set up in:
+     * $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares']
+     */
+    protected function getDriverMiddlewares(array $connectionParams): array
+    {
+        $middlewares = [];
+
+        foreach ($connectionParams['driverMiddlewares'] ?? [] as $className) {
+            if (!in_array(DriverMiddleware::class, class_implements($className) ?: [], true)) {
+                throw new \UnexpectedValueException('Doctrine Driver Middleware must implement \Doctrine\DBAL\Driver\Middleware', 1677958727);
+            }
+            $middlewares[] = GeneralUtility::makeInstance($className);
+        }
+
+        return $middlewares;
+    }
+
     /**
      * Creates a connection object based on the specified parameters
      */
@@ -198,9 +220,11 @@ class ConnectionPool
         }
 
         $connectionParams = $this->mapCustomDriver($connectionParams);
+        $middlewares = $this->getDriverMiddlewares($connectionParams);
+        $configuration = $middlewares ? (new Configuration())->setMiddlewares($middlewares) : null;
 
         /** @var Connection $conn */
-        $conn = DriverManager::getConnection($connectionParams);
+        $conn = DriverManager::getConnection($connectionParams, $configuration);
         $conn->prepareConnection($connectionParams['initCommands'] ?? '');
 
         // Register all custom data types in the type mapping
diff --git a/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst
new file mode 100644
index 000000000000..495eb882b0e1
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/12.3/Feature-100089-IntroduceDoctrineDBALv3drivermiddlewares.rst
@@ -0,0 +1,38 @@
+.. include:: /Includes.rst.txt
+
+.. _feature-100089-1677961107:
+
+================================================================
+Feature: #100089 - Introduce Doctrine DBAL v3 driver middlewares
+================================================================
+
+See :issue:`100089`
+
+Description
+===========
+
+Since v3, Doctrine DBAL supports adding custom driver middlewares. These
+middlewares act as a decorator around the actual `Driver` component.
+Subsequently, the `Connection`, `Statement` and `Result` components can be
+decorated as well. These middlewares must implement the
+:php:`\Doctrine\DBAL\Driver\Middleware` interface.
+A common use case would be a middleware for implementing sql logging capabilities.
+
+For more information on driver middlewares,
+see https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/architecture.html.
+Furthermore, you can look up the implementation of the
+:php:`\TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware` in ext:adminpanel
+as an example.
+
+Registering a new driver middleware
+===================================
+
+..  code-block:: php
+
+    $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections']['Default']['driverMiddlewares']['adminpanel_loggingmiddleware']
+        = \TYPO3\CMS\Adminpanel\Log\DoctrineSqlLoggingMiddleware::class;
+
+Impact
+======
+
+.. index:: Database, ext:core
-- 
GitLab