Connection.php 14.1 KB
Newer Older
1 2
<?php

3
namespace Drupal\Core\Database\Driver\mysql;
4

5
use Drupal\Core\Database\DatabaseAccessDeniedException;
6 7
use Drupal\Core\Database\DatabaseExceptionWrapper;

8
use Drupal\Core\Database\Database;
9
use Drupal\Core\Database\DatabaseNotFoundException;
10
use Drupal\Core\Database\DatabaseException;
11
use Drupal\Core\Database\Connection as DatabaseConnection;
12
use Drupal\Component\Utility\Unicode;
13

14
/**
15
 * @addtogroup database
16 17 18
 * @{
 */

19 20 21
/**
 * MySQL implementation of \Drupal\Core\Database\Connection.
 */
22
class Connection extends DatabaseConnection {
23

24 25 26 27 28
  /**
   * Error code for "Unknown database" error.
   */
  const DATABASE_NOT_FOUND = 1049;

29 30 31 32 33
  /**
   * Error code for "Access denied" error.
   */
  const ACCESS_DENIED = 1045;

34 35 36 37 38
  /**
   * Error code for "Can't initialize character set" error.
   */
  const UNSUPPORTED_CHARSET = 2019;

39 40 41 42 43 44 45 46 47 48
  /**
   * Driver-specific error code for "Unknown character set" error.
   */
  const UNKNOWN_CHARSET = 1115;

  /**
   * SQLSTATE error code for "Syntax error or access rule violation".
   */
  const SQLSTATE_SYNTAX_ERROR = 42000;

49
  /**
50
   * Flag to indicate if the cleanup function in __destruct() should run.
51
   *
52
   * @var bool
53
   */
54
  protected $needsCleanup = FALSE;
55

56 57 58 59 60 61 62 63 64 65
  /**
   * The minimal possible value for the max_allowed_packet setting of MySQL.
   *
   * @link https://mariadb.com/kb/en/mariadb/server-system-variables/#max_allowed_packet
   * @link https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_max_allowed_packet
   *
   * @var int
   */
  const MIN_MAX_ALLOWED_PACKET = 1024;

66 67 68
  /**
   * Constructs a Connection object.
   */
69
  public function __construct(\PDO $connection, array $connection_options = []) {
70 71
    parent::__construct($connection, $connection_options);

72 73
    // This driver defaults to transaction support, except if explicitly passed FALSE.
    $this->transactionSupport = !isset($connection_options['transactions']) || ($connection_options['transactions'] !== FALSE);
Dries's avatar
Dries committed
74

75 76
    // MySQL never supports transactional DDL.
    $this->transactionalDDLSupport = FALSE;
77

78
    $this->connectionOptions = $connection_options;
79
  }
80

81 82 83
  /**
   * {@inheritdoc}
   */
84
  public function query($query, array $args = [], $options = []) {
85 86
    try {
      return parent::query($query, $args, $options);
87 88
    }
    catch (DatabaseException $e) {
89 90 91 92 93 94 95 96 97 98 99
      if ($e->getPrevious()->errorInfo[1] == 1153) {
        // If a max_allowed_packet error occurs the message length is truncated.
        // This should prevent the error from recurring if the exception is
        // logged to the database using dblog or the like.
        $message = Unicode::truncateBytes($e->getMessage(), self::MIN_MAX_ALLOWED_PACKET);
        $e = new DatabaseExceptionWrapper($message, $e->getCode(), $e->getPrevious());
      }
      throw $e;
    }
  }

100 101 102
  /**
   * {@inheritdoc}
   */
103
  public static function open(array &$connection_options = []) {
104 105 106 107 108 109 110
    if (isset($connection_options['_dsn_utf8_fallback']) && $connection_options['_dsn_utf8_fallback'] === TRUE) {
      // Only used during the installer version check, as a fallback from utf8mb4.
      $charset = 'utf8';
    }
    else {
      $charset = 'utf8mb4';
    }
111 112 113 114 115 116 117 118
    // The DSN should use either a socket or a host/port.
    if (isset($connection_options['unix_socket'])) {
      $dsn = 'mysql:unix_socket=' . $connection_options['unix_socket'];
    }
    else {
      // Default to TCP connection on port 3306.
      $dsn = 'mysql:host=' . $connection_options['host'] . ';port=' . (empty($connection_options['port']) ? 3306 : $connection_options['port']);
    }
119 120 121
    // Character set is added to dsn to ensure PDO uses the proper character
    // set when escaping. This has security implications. See
    // https://www.drupal.org/node/1201452 for further discussion.
122
    $dsn .= ';charset=' . $charset;
123 124 125
    if (!empty($connection_options['database'])) {
      $dsn .= ';dbname=' . $connection_options['database'];
    }
126
    // Allow PDO options to be overridden.
127 128 129 130
    $connection_options += [
      'pdo' => [],
    ];
    $connection_options['pdo'] += [
131
      \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
132
      // So we don't have to mess around with cursors and unbuffered queries by default.
133
      \PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => TRUE,
134 135
      // Make sure MySQL returns all matched rows on update queries including
      // rows that actually didn't have to be updated because the values didn't
136
      // change. This matches common behavior among other database systems.
137
      \PDO::MYSQL_ATTR_FOUND_ROWS => TRUE,
138
      // Because MySQL's prepared statements skip the query cache, because it's dumb.
139
      \PDO::ATTR_EMULATE_PREPARES => TRUE,
140 141
      // Limit SQL to a single statement like mysqli.
      \PDO::MYSQL_ATTR_MULTI_STATEMENTS => FALSE,
142
    ];
143

144 145 146 147 148 149 150 151 152 153 154 155
    try {
      $pdo = new \PDO($dsn, $connection_options['username'], $connection_options['password'], $connection_options['pdo']);
    }
    catch (\PDOException $e) {
      if ($e->getCode() == static::DATABASE_NOT_FOUND) {
        throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e);
      }
      if ($e->getCode() == static::ACCESS_DENIED) {
        throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e);
      }
      throw $e;
    }
156

157
    // Force MySQL to use the UTF-8 character set. Also set the collation, if a
158
    // certain one has been set; otherwise, MySQL defaults to
159 160
    // 'utf8mb4_general_ci' (MySQL 5) or 'utf8mb4_0900_ai_ci' (MySQL 8) for
    // utf8mb4.
161
    if (!empty($connection_options['collation'])) {
162
      $pdo->exec('SET NAMES ' . $charset . ' COLLATE ' . $connection_options['collation']);
163 164
    }
    else {
165
      $pdo->exec('SET NAMES ' . $charset);
166
    }
167

168 169 170 171 172
    // Set MySQL init_commands if not already defined.  Default Drupal's MySQL
    // behavior to conform more closely to SQL standards.  This allows Drupal
    // to run almost seamlessly on many different kinds of database systems.
    // These settings force MySQL to behave the same as postgresql, or sqlite
    // in regards to syntax interpretation and invalid data handling.  See
173 174 175
    // https://www.drupal.org/node/344575 for further discussion. Also, as MySQL
    // 5.5 changed the meaning of TRADITIONAL we need to spell out the modes one
    // by one.
176 177 178
    $connection_options += [
      'init_commands' => [],
    ];
179 180 181 182 183 184 185 186

    $sql_mode = 'ANSI,STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,ONLY_FULL_GROUP_BY';
    // NO_AUTO_CREATE_USER is removed in MySQL 8.0.11
    // https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-11.html#mysqld-8-0-11-deprecation-removal
    $version_server = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION);
    if (version_compare($version_server, '8.0.11', '<')) {
      $sql_mode .= ',NO_AUTO_CREATE_USER';
    }
187
    $connection_options['init_commands'] += [
188
      'sql_mode' => "SET sql_mode = '$sql_mode'",
189
    ];
190

191 192 193 194
    // Execute initial commands.
    foreach ($connection_options['init_commands'] as $sql) {
      $pdo->exec($sql);
    }
195 196

    return $pdo;
197 198
  }

199 200 201 202 203 204 205 206 207 208 209 210 211
  /**
   * {@inheritdoc}
   */
  public function serialize() {
    // Cleanup the connection, much like __destruct() does it as well.
    if ($this->needsCleanup) {
      $this->nextIdDelete();
    }
    $this->needsCleanup = FALSE;

    return parent::serialize();
  }

212 213 214
  /**
   * {@inheritdoc}
   */
215 216 217 218 219 220
  public function __destruct() {
    if ($this->needsCleanup) {
      $this->nextIdDelete();
    }
  }

221
  public function queryRange($query, $from, $count, array $args = [], array $options = []) {
222
    return $this->query($query . ' LIMIT ' . (int) $from . ', ' . (int) $count, $args, $options);
223 224
  }

225
  public function queryTemporary($query, array $args = [], array $options = []) {
226
    $tablename = $this->generateTemporaryTableName();
227
    $this->query('CREATE TEMPORARY TABLE {' . $tablename . '} Engine=MEMORY ' . $query, $args, $options);
228
    return $tablename;
229 230
  }

231 232 233 234 235 236 237 238 239
  /**
   * {@inheritdoc}
   */
  protected function identifierQuote() {
    // The database is using the ANSI option on set up so use ANSI quotes and
    // not MySQL's custom backtick quote.
    return '"';
  }

240 241 242 243
  public function driver() {
    return 'mysql';
  }

244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
  /**
   * {@inheritdoc}
   */
  public function version() {
    if ($this->isMariaDb()) {
      return $this->getMariaDbVersionMatch();
    }

    return $this->getServerVersion();
  }

  /**
   * Determines whether the MySQL distribution is MariaDB or not.
   *
   * @return bool
   *   Returns TRUE if the distribution is MariaDB, or FALSE if not.
   */
  public function isMariaDb(): bool {
    return (bool) $this->getMariaDbVersionMatch();
  }

  /**
   * Gets the MariaDB portion of the server version.
   *
   * @return string
   *   The MariaDB portion of the server version if present, or NULL if not.
   */
  protected function getMariaDbVersionMatch(): ?string {
    // MariaDB may prefix its version string with '5.5.5-', which should be
    // ignored.
    // @see https://github.com/MariaDB/server/blob/f6633bf058802ad7da8196d01fd19d75c53f7274/include/mysql_com.h#L42.
    $regex = '/^(?:5\.5\.5-)?(\d+\.\d+\.\d+.*-mariadb.*)/i';

    preg_match($regex, $this->getServerVersion(), $matches);
    return (empty($matches[1])) ? NULL : $matches[1];
  }

  /**
   * Gets the server version.
   *
   * @return string
   *   The PDO server version.
   */
  protected function getServerVersion(): string {
    return $this->connection->getAttribute(\PDO::ATTR_SERVER_VERSION);
  }

291 292 293 294
  public function databaseType() {
    return 'mysql';
  }

295 296 297 298 299 300
  /**
   * Overrides \Drupal\Core\Database\Connection::createDatabase().
   *
   * @param string $database
   *   The name of the database to create.
   *
301
   * @throws \Drupal\Core\Database\DatabaseNotFoundException
302 303 304 305 306 307 308
   */
  public function createDatabase($database) {
    // Escape the database name.
    $database = Database::getConnection()->escapeDatabase($database);

    try {
      // Create the database and set it as active.
309 310
      $this->connection->exec("CREATE DATABASE $database");
      $this->connection->exec("USE $database");
311 312 313 314 315 316
    }
    catch (\Exception $e) {
      throw new DatabaseNotFoundException($e->getMessage());
    }
  }

317 318 319 320
  public function mapConditionOperator($operator) {
    // We don't want to override any of the defaults.
    return NULL;
  }
321 322

  public function nextId($existing_id = 0) {
323
    $new_id = $this->query('INSERT INTO {sequences} () VALUES ()', [], ['return' => Database::RETURN_INSERT_ID]);
324 325 326 327 328 329 330 331 332
    // This should only happen after an import or similar event.
    if ($existing_id >= $new_id) {
      // If we INSERT a value manually into the sequences table, on the next
      // INSERT, MySQL will generate a larger value. However, there is no way
      // of knowing whether this value already exists in the table. MySQL
      // provides an INSERT IGNORE which would work, but that can mask problems
      // other than duplicate keys. Instead, we use INSERT ... ON DUPLICATE KEY
      // UPDATE in such a way that the UPDATE does not do anything. This way,
      // duplicate keys do not generate errors but everything else does.
333 334
      $this->query('INSERT INTO {sequences} (value) VALUES (:value) ON DUPLICATE KEY UPDATE value = value', [':value' => $existing_id]);
      $new_id = $this->query('INSERT INTO {sequences} () VALUES ()', [], ['return' => Database::RETURN_INSERT_ID]);
335
    }
336
    $this->needsCleanup = TRUE;
337 338 339
    return $new_id;
  }

340
  public function nextIdDelete() {
341 342
    // While we want to clean up the table to keep it up from occupying too
    // much storage and memory, we must keep the highest value in the table
343
    // because InnoDB uses an in-memory auto-increment counter as long as the
344 345 346 347 348
    // server runs. When the server is stopped and restarted, InnoDB
    // reinitializes the counter for each table for the first INSERT to the
    // table based solely on values from the table so deleting all values would
    // be a problem in this case. Also, TRUNCATE resets the auto increment
    // counter.
349 350
    try {
      $max_id = $this->query('SELECT MAX(value) FROM {sequences}')->fetchField();
351
      // We know we are using MySQL here, no need for the slower ::delete().
352
      $this->query('DELETE FROM {sequences} WHERE value < :value', [':value' => $max_id]);
353 354 355 356 357 358 359
    }
    // During testing, this function is called from shutdown with the
    // simpletest prefix stored in $this->connection, and those tables are gone
    // by the time shutdown is called so we need to ignore the database
    // errors. There is no problem with completely ignoring errors here: if
    // these queries fail, the sequence will work just fine, just use a bit
    // more database storage and memory.
360
    catch (DatabaseException $e) {
361
    }
362
  }
363 364 365 366

  /**
   * Overridden to work around issues to MySQL not supporting transactional DDL.
   */
367 368 369 370 371 372
  protected function popCommittableTransactions() {
    // Commit all the committable layers.
    foreach (array_reverse($this->transactionLayers) as $name => $active) {
      // Stop once we found an active transaction.
      if ($active) {
        break;
373 374 375
      }

      // If there are no more layers left then we should commit.
376
      unset($this->transactionLayers[$name]);
377
      if (empty($this->transactionLayers)) {
378
        $this->doCommit();
379 380 381 382 383 384
      }
      else {
        // Attempt to release this savepoint in the standard way.
        try {
          $this->query('RELEASE SAVEPOINT ' . $name);
        }
385
        catch (DatabaseExceptionWrapper $e) {
386 387 388 389 390 391
          // However, in MySQL (InnoDB), savepoints are automatically committed
          // when tables are altered or created (DDL transactions are not
          // supported). This can cause exceptions due to trying to release
          // savepoints which no longer exist.
          //
          // To avoid exceptions when no actual error has occurred, we silently
392
          // succeed for MySQL error code 1305 ("SAVEPOINT does not exist").
393
          if ($e->getPrevious()->errorInfo[1] == '1305') {
394
            // If one SAVEPOINT was released automatically, then all were.
395
            // Therefore, clean the transaction stack.
396
            $this->transactionLayers = [];
397 398
            // We also have to explain to PDO that the transaction stack has
            // been cleaned-up.
399
            $this->doCommit();
400 401
          }
          else {
402 403 404 405 406 407
            throw $e;
          }
        }
      }
    }
  }
408

409 410 411 412
}


/**
413
 * @} End of "addtogroup database".
414
 */