Commit d7313cce authored by catch's avatar catch
Browse files

Issue #3313355 by mondrake, mfb, Anchal_gupta, daffie, larowlan, alexpott:...

Issue #3313355 by mondrake, mfb, Anchal_gupta, daffie, larowlan, alexpott: Allow the database query log to be dispatched as log events
parent d746b86d
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -415,6 +415,9 @@ services:
    tags:
      - { name: event_subscriber }
  Drupal\Core\Database\ReplicaKillSwitch: '@database.replica_kill_switch'
  Drupal\Core\Database\EventSubscriber\StatementExecutionSubscriber:
    tags:
      - { name: event_subscriber }
  datetime.time:
    class: Drupal\Component\Datetime\Time
    arguments: ['@request_stack']
+158 −0
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@
namespace Drupal\Core\Database;

use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Database\Event\DatabaseEvent;
use Drupal\Core\Database\Exception\EventException;
use Drupal\Core\Database\Query\Condition;
use Drupal\Core\Database\Query\Delete;
use Drupal\Core\Database\Query\Insert;
@@ -216,6 +218,14 @@ abstract class Connection {
   */
  protected $identifierQuotes;

  /**
   * Tracks the database API events to be dispatched.
   *
   * For performance reasons, database API events are not yielded by default.
   * Call ::enableEvents() to enable them.
   */
  private array $enabledEvents = [];

  /**
   * Constructs a Connection object.
   *
@@ -1867,4 +1877,152 @@ public function hasJson(): bool {
    }
  }

  /**
   * Returns the status of a database API event toggle.
   *
   * @param string $eventName
   *   The name of the event to check.
   *
   * @return bool
   *   TRUE if the event is going to be fired by the database API, FALSE
   *   otherwise.
   */
  public function isEventEnabled(string $eventName): bool {
    return $this->enabledEvents[$eventName] ?? FALSE;
  }

  /**
   * Enables database API events dispatching.
   *
   * @param string[] $eventNames
   *   A list of database events to be enabled.
   *
   * @return static
   */
  public function enableEvents(array $eventNames): static {
    foreach ($eventNames as $eventName) {
      assert(class_exists($eventName), "Event class {$eventName} does not exist");
      $this->enabledEvents[$eventName] = TRUE;
    }
    return $this;
  }

  /**
   * Disables database API events dispatching.
   *
   * @param string[] $eventNames
   *   A list of database events to be disabled.
   *
   * @return static
   */
  public function disableEvents(array $eventNames): static {
    foreach ($eventNames as $eventName) {
      assert(class_exists($eventName), "Event class {$eventName} does not exist");
      $this->enabledEvents[$eventName] = FALSE;
    }
    return $this;
  }

  /**
   * Dispatches a database API event via the container dispatcher.
   *
   * @param \Drupal\Core\Database\Event\DatabaseEvent $event
   *   The database event.
   * @param string|null $eventName
   *   (Optional) the name of the event to dispatch.
   *
   * @return \Drupal\Core\Database\Event\DatabaseEvent
   *   The database event.
   *
   * @throws \Drupal\Core\Database\Exception\EventException
   *   If the container is not initialized.
   */
  public function dispatchEvent(DatabaseEvent $event, ?string $eventName = NULL): DatabaseEvent {
    if (\Drupal::hasService('event_dispatcher')) {
      return \Drupal::service('event_dispatcher')->dispatch($event, $eventName);
    }
    throw new EventException('The event dispatcher service is not available. Database API events can only be fired if the container is initialized');
  }

  /**
   * Determine the last non-database method that called the database API.
   *
   * Traversing the call stack from the very first call made during the
   * request, we define "the routine that called this query" as the last entry
   * in the call stack that is not any method called from the namespace of the
   * database driver, is not inside the Drupal\Core\Database namespace and does
   * have a file (which excludes call_user_func_array(), anonymous functions
   * and similar). That makes the climbing logic very simple, and handles the
   * variable stack depth caused by the query builders.
   *
   * See the @link http://php.net/debug_backtrace debug_backtrace() @endlink
   * function.
   *
   * @return array
   *   This method returns a stack trace entry similar to that generated by
   *   debug_backtrace(). However, it flattens the trace entry and the trace
   *   entry before it so that we get the function and args of the function that
   *   called into the database system, not the function and args of the
   *   database call itself.
   */
  public function findCallerFromDebugBacktrace(): array {
    $stack = $this->removeDatabaseEntriesFromDebugBacktrace($this->getDebugBacktrace(), $this->getConnectionOptions()['namespace']);
    // Return the first function call whose stack entry has a 'file' key, that
    // is, it is not a callback or a closure.
    for ($i = 0; $i < count($stack); $i++) {
      if (!empty($stack[$i]['file'])) {
        return [
          'file' => $stack[$i]['file'],
          'line' => $stack[$i]['line'],
          'function' => $stack[$i + 1]['function'],
          'class' => $stack[$i + 1]['class'] ?? NULL,
          'type' => $stack[$i + 1]['type'] ?? NULL,
          'args' => $stack[$i + 1]['args'] ?? [],
        ];
      }
    }

    return [];
  }

  /**
   * Removes database related calls from a backtrace array.
   *
   * @param array $backtrace
   *   A standard PHP backtrace. Passed by reference.
   * @param string $driver_namespace
   *   The PHP namespace of the database driver.
   *
   * @return array
   *   The cleaned backtrace array.
   */
  public static function removeDatabaseEntriesFromDebugBacktrace(array $backtrace, string $driver_namespace): array {
    // Starting from the very first entry processed during the request, find
    // the first function call that can be identified as a call to a
    // method/function in the database layer.
    for ($n = count($backtrace) - 1; $n >= 0; $n--) {
      // If the call was made from a function, 'class' will be empty. We give
      // it a default empty string value in that case.
      $class = $backtrace[$n]['class'] ?? '';
      if (str_starts_with($class, __NAMESPACE__) || str_starts_with($class, $driver_namespace)) {
        break;
      }
    }

    return array_values(array_slice($backtrace, $n));
  }

  /**
   * Gets the debug backtrace.
   *
   * Wraps the debug_backtrace function to allow mocking results in PHPUnit
   * tests.
   *
   * @return array[]
   *   The debug backtrace.
   */
  protected function getDebugBacktrace(): array {
    return debug_backtrace();
  }

}
+10 −0
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@
namespace Drupal\Core\Database;

use Composer\Autoload\ClassLoader;
use Drupal\Core\Database\Event\StatementExecutionEndEvent;
use Drupal\Core\Database\Event\StatementExecutionStartEvent;
use Drupal\Core\Extension\ExtensionDiscovery;

/**
@@ -122,6 +124,10 @@ 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->setLogger(self::$logs[$key]);
        }
      }
@@ -450,6 +456,10 @@ 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->setLogger(self::$logs[$key]);
    }

+24 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Database\Event;

use Drupal\Component\EventDispatcher\Event;

/**
 * Represents a database event.
 */
abstract class DatabaseEvent extends Event {

  /**
   * The time of the event.
   */
  public readonly float $time;

  /**
   * Constructs a DatabaseEvent object.
   */
  public function __construct() {
    $this->time = microtime(TRUE);
  }

}
+54 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Database\Event;

/**
 * Represents the end of a statement execution as an event.
 */
class StatementExecutionEndEvent extends DatabaseEvent {

  /**
   * Constructs a StatementExecutionEndEvent object.
   *
   * 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.
   */
  public function __construct(
    public readonly int $statementObjectId,
    public readonly string $key,
    public readonly string $target,
    public readonly string $queryString,
    public readonly array $args,
    public readonly array $caller,
    public readonly float $startTime,
  ) {
    parent::__construct();
  }

  /**
   * Gets the query execution elapsed time.
   *
   * @return float
   *   The elapsed time.
   */
  public function getElapsedTime(): float {
    return $this->time - $this->startTime;
  }

}
Loading