diff --git a/core/lib/Drupal/Core/Database/Database.php b/core/lib/Drupal/Core/Database/Database.php index b7fd9ed419c478b379ae8bcdeea307d69cc88cfa..c76a23cafd768561ed3f600283184af48b84b00f 100644 --- a/core/lib/Drupal/Core/Database/Database.php +++ b/core/lib/Drupal/Core/Database/Database.php @@ -3,8 +3,7 @@ namespace Drupal\Core\Database; use Composer\Autoload\ClassLoader; -use Drupal\Core\Database\Event\StatementExecutionEndEvent; -use Drupal\Core\Database\Event\StatementExecutionStartEvent; +use Drupal\Core\Database\Event\StatementEvent; use Drupal\Core\Extension\DatabaseDriverList; use Drupal\Core\Cache\NullBackend; @@ -125,10 +124,7 @@ final public static function startLog($logging_key, $key = 'default') { // logging object associated with it. if (!empty(self::$connections[$key])) { foreach (self::$connections[$key] as $connection) { - $connection->enableEvents([ - StatementExecutionStartEvent::class, - StatementExecutionEndEvent::class, - ]); + $connection->enableEvents(StatementEvent::all()); $connection->setLogger(self::$logs[$key]); } } @@ -469,10 +465,7 @@ final protected static function openConnection($key, $target) { // If we have any active logging objects for this connection key, we need // to associate them with the connection we just opened. if (!empty(self::$logs[$key])) { - $new_connection->enableEvents([ - StatementExecutionStartEvent::class, - StatementExecutionEndEvent::class, - ]); + $new_connection->enableEvents(StatementEvent::all()); $new_connection->setLogger(self::$logs[$key]); } diff --git a/core/lib/Drupal/Core/Database/Event/StatementEvent.php b/core/lib/Drupal/Core/Database/Event/StatementEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..452c2003538d14a9b99e82bc9b61cd5c9736f907 --- /dev/null +++ b/core/lib/Drupal/Core/Database/Event/StatementEvent.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Database\Event; + +/** + * Enumeration of the statement related database events. + */ +enum StatementEvent: string { + + case ExecutionStart = StatementExecutionStartEvent::class; + case ExecutionEnd = StatementExecutionEndEvent::class; + case ExecutionFailure = StatementExecutionFailureEvent::class; + + /** + * Returns an array with all statement related events. + * + * @return list<class-string<\Drupal\Core\Database\Event\DatabaseEvent>> + * An array with all statement related events. + */ + public static function all(): array { + return array_map(fn(self $case) => $case->value, self::cases()); + } + +} diff --git a/core/lib/Drupal/Core/Database/Event/StatementExecutionFailureEvent.php b/core/lib/Drupal/Core/Database/Event/StatementExecutionFailureEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..682e59f23898319ba266c1f3a42b85e4d4cf0a26 --- /dev/null +++ b/core/lib/Drupal/Core/Database/Event/StatementExecutionFailureEvent.php @@ -0,0 +1,53 @@ +<?php + +namespace Drupal\Core\Database\Event; + +/** + * Represents the failure of a statement execution as an event. + */ +class StatementExecutionFailureEvent extends StatementExecutionEndEvent { + + /** + * Constructor. + * + * See 'Customizing database settings' in settings.php for an explanation of + * the $key and $target connection values. + * + * @param int $statementObjectId + * The id of the StatementInterface object as returned by spl_object_id(). + * @param string $key + * The database connection key. + * @param string $target + * The database connection target. + * @param string $queryString + * The SQL statement string being executed, with placeholders. + * @param array $args + * The placeholders' replacement values. + * @param array $caller + * A normalized debug backtrace entry representing the last non-db method + * called. + * @param float $startTime + * The time of the statement execution start. + * @param string $exceptionClass + * The class of the exception that was thrown. + * @param int|string $exceptionCode + * The code of the exception that was thrown. + * @param string $exceptionMessage + * The message of the exception that was thrown. + */ + public function __construct( + int $statementObjectId, + string $key, + string $target, + string $queryString, + array $args, + array $caller, + float $startTime, + public readonly string $exceptionClass, + public readonly int|string $exceptionCode, + public readonly string $exceptionMessage, + ) { + parent::__construct($statementObjectId, $key, $target, $queryString, $args, $caller, $startTime); + } + +} diff --git a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php index 20a3378302f938ebcde46591159bf26bde380af8..96e3acfa3d7068929f79eab7a5713e17efe6c8c8 100644 --- a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php +++ b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Database; use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionFailureEvent; use Drupal\Core\Database\Event\StatementExecutionStartEvent; /** @@ -111,15 +112,27 @@ public function execute($args = [], $options = []) { $this->connection->dispatchEvent($startEvent); } - // Prepare the query. - $statement = $this->getStatement($this->queryString, $args); - if (!$statement) { - $this->throwPDOException(); + // Prepare and execute the statement. + try { + $statement = $this->getStatement($this->queryString, $args); + $return = $statement->execute($args); } - - $return = $statement->execute($args); - if (!$return) { - $this->throwPDOException(); + catch (\Exception $e) { + if (isset($startEvent) && $this->connection->isEventEnabled(StatementExecutionFailureEvent::class)) { + $this->connection->dispatchEvent(new StatementExecutionFailureEvent( + $startEvent->statementObjectId, + $startEvent->key, + $startEvent->target, + $startEvent->queryString, + $startEvent->args, + $startEvent->caller, + $startEvent->time, + get_class($e), + $e->getCode(), + $e->getMessage(), + )); + } + throw $e; } // Fetch all the data from the reply, in order to release any lock as soon @@ -150,8 +163,14 @@ public function execute($args = [], $options = []) { /** * Throw a PDO Exception based on the last PDO error. + * + * @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is + * no replacement. + * + * @see https://www.drupal.org/node/3410663 */ protected function throwPDOException(): void { + @trigger_error(__METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3410663', E_USER_DEPRECATED); $error_info = $this->connection->errorInfo(); // We rebuild a message formatted in the same way as PDO. $exception = new \PDOException("SQLSTATE[" . $error_info[0] . "]: General error " . $error_info[1] . ": " . $error_info[2]); diff --git a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php index b0474045a3752f096702499f9f7a29a3e1e59379..c6dd50383b184302abb9e26e633600209e62773e 100644 --- a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php +++ b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Database; use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionFailureEvent; use Drupal\Core\Database\Event\StatementExecutionStartEvent; // cSpell:ignore maxlen driverdata INOUT @@ -108,8 +109,27 @@ public function execute($args = [], $options = []) { $this->connection->dispatchEvent($startEvent); } - $return = $this->clientStatement->execute($args); - $this->markResultsetIterable($return); + try { + $return = $this->clientStatement->execute($args); + $this->markResultsetIterable($return); + } + catch (\Exception $e) { + if (isset($startEvent) && $this->connection->isEventEnabled(StatementExecutionFailureEvent::class)) { + $this->connection->dispatchEvent(new StatementExecutionFailureEvent( + $startEvent->statementObjectId, + $startEvent->key, + $startEvent->target, + $startEvent->queryString, + $startEvent->args, + $startEvent->caller, + $startEvent->time, + get_class($e), + $e->getCode(), + $e->getMessage(), + )); + } + throw $e; + } if (isset($startEvent) && $this->connection->isEventEnabled(StatementExecutionEndEvent::class)) { $this->connection->dispatchEvent(new StatementExecutionEndEvent( diff --git a/core/modules/system/tests/modules/database_test/src/EventSubscriber/DatabaseEventSubscriber.php b/core/modules/system/tests/modules/database_test/src/EventSubscriber/DatabaseEventSubscriber.php index e35d333fd234216529d3a1287eab916a542851c0..767edc64a9ed40bf00ec04fd730868db8ae1a7df 100644 --- a/core/modules/system/tests/modules/database_test/src/EventSubscriber/DatabaseEventSubscriber.php +++ b/core/modules/system/tests/modules/database_test/src/EventSubscriber/DatabaseEventSubscriber.php @@ -3,6 +3,7 @@ namespace Drupal\database_test\EventSubscriber; use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionFailureEvent; use Drupal\Core\Database\Event\StatementExecutionStartEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -21,6 +22,11 @@ class DatabaseEventSubscriber implements EventSubscriberInterface { */ public int $countStatementEnds = 0; + /** + * A counter of failed statement executions. + */ + public int $countStatementFailures = 0; + /** * A map of statements being executed. */ @@ -33,6 +39,7 @@ public static function getSubscribedEvents(): array { return [ StatementExecutionStartEvent::class => 'onStatementExecutionStart', StatementExecutionEndEvent::class => 'onStatementExecutionEnd', + StatementExecutionFailureEvent::class => 'onStatementExecutionFailure', ]; } @@ -58,4 +65,15 @@ public function onStatementExecutionEnd(StatementExecutionEndEvent $event): void $this->countStatementEnds++; } + /** + * Subscribes to a statement execution failure event. + * + * @param \Drupal\Core\Database\Event\StatementExecutionFailureEvent $event + * The database event. + */ + public function onStatementExecutionFailure(StatementExecutionFailureEvent $event): void { + unset($this->statementIdsInExecution[$event->statementObjectId]); + $this->countStatementFailures++; + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Database/DatabaseEventTest.php b/core/tests/Drupal/KernelTests/Core/Database/DatabaseEventTest.php index 895b61b880746ad4e9483a45713869f20ab623f8..ecd2b580f38d58965c7e4c3a6fd866fa090ae308 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DatabaseEventTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DatabaseEventTest.php @@ -2,7 +2,9 @@ namespace Drupal\KernelTests\Core\Database; +use Drupal\Core\Database\Event\StatementEvent; use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionFailureEvent; use Drupal\Core\Database\Event\StatementExecutionStartEvent; use Drupal\database_test\EventSubscriber\DatabaseEventSubscriber; @@ -56,23 +58,40 @@ public function testStatementExecutionEvents(): void { $this->assertTrue($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); $this->assertTrue($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); - // Disable both events, no more events captured. - $this->connection->disableEvents([ - StatementExecutionStartEvent::class, - StatementExecutionEndEvent::class, - ]); + // Enable the statement execution failure event and execute a failing + // query. + $this->connection->enableEvents([StatementExecutionFailureEvent::class]); + try { + $this->connection->query('bananas on the palm tree'); + $this->fail('An exception was expected, but was not thrown'); + } + catch (\Exception $e) { + // Expected, keep going. + } + $this->assertSame(3, $subscriber->countStatementStarts); + $this->assertSame(1, $subscriber->countStatementEnds); + $this->assertSame(1, $subscriber->countStatementFailures); + $this->assertEmpty($subscriber->statementIdsInExecution); + $this->assertTrue($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); + $this->assertTrue($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); + $this->assertTrue($this->connection->isEventEnabled(StatementExecutionFailureEvent::class)); + + // Disable all events, no more events captured. + $this->connection->disableEvents(StatementEvent::all()); $this->connection->query('SELECT * FROM {test}'); - $this->assertSame(2, $subscriber->countStatementStarts); + $this->assertSame(3, $subscriber->countStatementStarts); $this->assertSame(1, $subscriber->countStatementEnds); + $this->assertSame(1, $subscriber->countStatementFailures); $this->assertEmpty($subscriber->statementIdsInExecution); $this->assertFalse($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); $this->assertFalse($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); + $this->assertFalse($this->connection->isEventEnabled(StatementExecutionFailureEvent::class)); // Enable the statement execution end only, no events captured since the // start event is required before the end one can be fired. $this->connection->enableEvents([StatementExecutionEndEvent::class]); $this->connection->query('SELECT * FROM {test}'); - $this->assertSame(2, $subscriber->countStatementStarts); + $this->assertSame(3, $subscriber->countStatementStarts); $this->assertSame(1, $subscriber->countStatementEnds); $this->assertEmpty($subscriber->statementIdsInExecution); $this->assertFalse($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); diff --git a/core/tests/Drupal/Tests/Core/Database/DatabaseEventsTest.php b/core/tests/Drupal/Tests/Core/Database/DatabaseEventsTest.php index 433a72c49cabe80b93919c24c8712950e7c2dcda..f8fd9858d7ce70c0a6df932eb5b46bddef533802 100644 --- a/core/tests/Drupal/Tests/Core/Database/DatabaseEventsTest.php +++ b/core/tests/Drupal/Tests/Core/Database/DatabaseEventsTest.php @@ -6,7 +6,9 @@ use Drupal\Core\Database\Connection; use Drupal\Core\Database\Event\DatabaseEvent; +use Drupal\Core\Database\Event\StatementEvent; use Drupal\Core\Database\Event\StatementExecutionEndEvent; +use Drupal\Core\Database\Event\StatementExecutionFailureEvent; use Drupal\Core\Database\Event\StatementExecutionStartEvent; use Drupal\Core\Database\Exception\EventException; use Drupal\Tests\Core\Database\Stub\StubConnection; @@ -40,17 +42,20 @@ protected function setUp(): void { * @covers ::disableEvents */ public function testEventEnablingAndDisabling(): void { - $this->connection->enableEvents([ - StatementExecutionStartEvent::class, - StatementExecutionEndEvent::class, - ]); + $this->connection->enableEvents(StatementEvent::all()); $this->assertTrue($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); $this->assertTrue($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); + $this->assertTrue($this->connection->isEventEnabled(StatementExecutionFailureEvent::class)); $this->connection->disableEvents([ StatementExecutionEndEvent::class, ]); $this->assertTrue($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); $this->assertFalse($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); + $this->assertTrue($this->connection->isEventEnabled(StatementExecutionFailureEvent::class)); + $this->connection->disableEvents(StatementEvent::all()); + $this->assertFalse($this->connection->isEventEnabled(StatementExecutionStartEvent::class)); + $this->assertFalse($this->connection->isEventEnabled(StatementExecutionEndEvent::class)); + $this->assertFalse($this->connection->isEventEnabled(StatementExecutionFailureEvent::class)); } /**