Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,5 @@
name: SQLite
type: module
description: 'Provides the SQLite database driver.'
package: Core
version: VERSION

View File

@ -0,0 +1,537 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Component\Utility\FilterArray;
use Drupal\Core\Database\Connection as DatabaseConnection;
use Drupal\Core\Database\DatabaseNotFoundException;
use Drupal\Core\Database\ExceptionHandler;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\Database\SupportsTemporaryTablesInterface;
use Drupal\Core\Database\Transaction\TransactionManagerInterface;
/**
* SQLite implementation of \Drupal\Core\Database\Connection.
*/
class Connection extends DatabaseConnection implements SupportsTemporaryTablesInterface {
/**
* Error code for "Unable to open database file" error.
*/
const DATABASE_NOT_FOUND = 14;
/**
* {@inheritdoc}
*/
protected $statementWrapperClass = NULL;
/**
* A map of condition operators to SQLite operators.
*
* We don't want to override any of the defaults.
*
* @var string[][]
*/
protected static $sqliteConditionOperatorMap = [
'LIKE' => ['postfix' => " ESCAPE '\\'"],
'NOT LIKE' => ['postfix' => " ESCAPE '\\'"],
'LIKE BINARY' => ['postfix' => " ESCAPE '\\'", 'operator' => 'GLOB'],
'NOT LIKE BINARY' => ['postfix' => " ESCAPE '\\'", 'operator' => 'NOT GLOB'],
];
/**
* All databases attached to the current database.
*
* This is used to allow prefixes to be safely handled without locking the
* table.
*
* @var array
*/
protected $attachedDatabases = [];
/**
* Whether or not a table has been dropped this request.
*
* The destructor will only try to get rid of unnecessary databases if there
* is potential of them being empty.
*
* This variable is set to public because Schema needs to
* access it. However, it should not be manually set.
*
* @var bool
*/
public $tableDropped = FALSE;
/**
* {@inheritdoc}
*/
protected $transactionalDDLSupport = TRUE;
/**
* {@inheritdoc}
*/
protected $identifierQuotes = ['"', '"'];
/**
* Constructs a \Drupal\sqlite\Driver\Database\sqlite\Connection object.
*/
public function __construct(\PDO $connection, array $connection_options) {
parent::__construct($connection, $connection_options);
// Empty prefix means query the main database -- no need to attach anything.
$prefix = $this->connectionOptions['prefix'] ?? '';
if ($prefix !== '') {
$this->attachDatabase($prefix);
// Add a ., so queries become prefix.table, which is proper syntax for
// querying an attached database.
$prefix .= '.';
}
// Regenerate the prefix.
$this->setPrefix($prefix);
}
/**
* {@inheritdoc}
*/
public static function open(array &$connection_options = []) {
// Allow PDO options to be overridden.
$connection_options += [
'pdo' => [],
];
$connection_options['pdo'] += [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
// Convert numeric values to strings when fetching.
\PDO::ATTR_STRINGIFY_FETCHES => TRUE,
];
try {
$pdo = new PDOConnection('sqlite:' . $connection_options['database'], '', '', $connection_options['pdo']);
}
catch (\PDOException $e) {
if ($e->getCode() == static::DATABASE_NOT_FOUND) {
throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e);
}
// SQLite doesn't have a distinct error code for access denied, so don't
// deal with that case.
throw $e;
}
// Create functions needed by SQLite.
$pdo->sqliteCreateFunction('if', [__CLASS__, 'sqlFunctionIf']);
$pdo->sqliteCreateFunction('greatest', [__CLASS__, 'sqlFunctionGreatest']);
$pdo->sqliteCreateFunction('least', [__CLASS__, 'sqlFunctionLeast']);
$pdo->sqliteCreateFunction('pow', 'pow', 2);
$pdo->sqliteCreateFunction('exp', 'exp', 1);
$pdo->sqliteCreateFunction('length', 'strlen', 1);
$pdo->sqliteCreateFunction('md5', 'md5', 1);
$pdo->sqliteCreateFunction('concat', [__CLASS__, 'sqlFunctionConcat']);
$pdo->sqliteCreateFunction('concat_ws', [__CLASS__, 'sqlFunctionConcatWs']);
$pdo->sqliteCreateFunction('substring', [__CLASS__, 'sqlFunctionSubstring'], 3);
$pdo->sqliteCreateFunction('substring_index', [__CLASS__, 'sqlFunctionSubstringIndex'], 3);
$pdo->sqliteCreateFunction('rand', [__CLASS__, 'sqlFunctionRand']);
$pdo->sqliteCreateFunction('regexp', [__CLASS__, 'sqlFunctionRegexp']);
// SQLite does not support the LIKE BINARY operator, so we overload the
// non-standard GLOB operator for case-sensitive matching. Another option
// would have been to override another non-standard operator, MATCH, but
// that does not support the NOT keyword prefix.
$pdo->sqliteCreateFunction('glob', [__CLASS__, 'sqlFunctionLikeBinary']);
// Create a user-space case-insensitive collation with UTF-8 support.
$pdo->sqliteCreateCollation('NOCASE_UTF8', ['Drupal\Component\Utility\Unicode', 'strcasecmp']);
// Set SQLite init_commands if not already defined. Enable the Write-Ahead
// Logging (WAL) for SQLite. See https://www.drupal.org/node/2348137 and
// https://www.sqlite.org/wal.html.
$connection_options += [
'init_commands' => [],
];
$connection_options['init_commands'] += [
'wal' => "PRAGMA journal_mode=WAL",
];
// Execute sqlite init_commands.
if (isset($connection_options['init_commands'])) {
$pdo->exec(implode('; ', $connection_options['init_commands']));
}
return $pdo;
}
/**
* Destructor for the SQLite connection.
*
* We prune empty databases on destruct, but only if tables have been
* dropped. This is especially needed when running the test suite, which
* creates and destroy databases several times in a row.
*/
public function __destruct() {
if ($this->tableDropped && !empty($this->attachedDatabases)) {
foreach ($this->attachedDatabases as $prefix) {
// Check if the database is now empty, ignore the internal SQLite
// tables.
try {
$count = $this->query('SELECT COUNT(*) FROM ' . $prefix . '.sqlite_master WHERE type = :type AND name NOT LIKE :pattern', [':type' => 'table', ':pattern' => 'sqlite_%'])->fetchField();
// We can prune the database file if it doesn't have any tables.
if ($count == 0 && $this->connectionOptions['database'] != ':memory:' && file_exists($this->connectionOptions['database'] . '-' . $prefix)) {
// Detach the database.
$this->query('DETACH DATABASE :schema', [':schema' => $prefix]);
// Destroy the database file.
unlink($this->connectionOptions['database'] . '-' . $prefix);
}
}
catch (\Exception) {
// Ignore the exception and continue. There is nothing we can do here
// to report the error or fail safe.
}
}
}
parent::__destruct();
}
/**
* {@inheritdoc}
*/
public function attachDatabase(string $database): void {
// Only attach the database once.
if (!isset($this->attachedDatabases[$database])) {
// In memory database use ':memory:' as database name. According to
// http://www.sqlite.org/inmemorydb.html it will open a unique database so
// attaching it twice is not a problem.
$database_file = $this->connectionOptions['database'] !== ':memory:' ? $this->connectionOptions['database'] . '-' . $database : $this->connectionOptions['database'];
$this->query('ATTACH DATABASE :database_file AS :database', [':database_file' => $database_file, ':database' => $database]);
$this->attachedDatabases[$database] = $database;
}
}
/**
* Gets all the attached databases.
*
* @return array
* An array of attached database names.
*
* @see \Drupal\sqlite\Driver\Database\sqlite\Connection::__construct()
*/
public function getAttachedDatabases() {
return $this->attachedDatabases;
}
/**
* SQLite compatibility implementation for the IF() SQL function.
*/
public static function sqlFunctionIf($condition, $expr1, $expr2 = NULL) {
return $condition ? $expr1 : $expr2;
}
/**
* SQLite compatibility implementation for the GREATEST() SQL function.
*/
public static function sqlFunctionGreatest() {
$args = func_get_args();
foreach ($args as $v) {
if (!isset($v)) {
unset($args);
}
}
if (count($args)) {
return max($args);
}
else {
return NULL;
}
}
/**
* SQLite compatibility implementation for the LEAST() SQL function.
*/
public static function sqlFunctionLeast() {
// Remove all NULL, FALSE and empty strings values but leaves 0 (zero)
// values.
$values = FilterArray::removeEmptyStrings(func_get_args());
return count($values) < 1 ? NULL : min($values);
}
/**
* SQLite compatibility implementation for the CONCAT() SQL function.
*/
public static function sqlFunctionConcat() {
$args = func_get_args();
return implode('', $args);
}
/**
* SQLite compatibility implementation for the CONCAT_WS() SQL function.
*
* @see http://dev.mysql.com/doc/refman/5.6/en/string-functions.html#function_concat-ws
*/
public static function sqlFunctionConcatWs() {
$args = func_get_args();
$separator = array_shift($args);
// If the separator is NULL, the result is NULL.
if ($separator === FALSE || is_null($separator)) {
return NULL;
}
// Skip any NULL values after the separator argument.
$args = array_filter($args, function ($value) {
return !is_null($value);
});
return implode($separator, $args);
}
/**
* SQLite compatibility implementation for the SUBSTRING() SQL function.
*/
public static function sqlFunctionSubstring($string, $from, $length) {
return substr($string, $from - 1, $length);
}
/**
* SQLite compatibility implementation for the SUBSTRING_INDEX() SQL function.
*/
public static function sqlFunctionSubstringIndex($string, $delimiter, $count) {
// If string is empty, simply return an empty string.
if (empty($string)) {
return '';
}
$end = 0;
for ($i = 0; $i < $count; $i++) {
$end = strpos($string, $delimiter, $end + 1);
if ($end === FALSE) {
$end = strlen($string);
}
}
return substr($string, 0, $end);
}
/**
* SQLite compatibility implementation for the RAND() SQL function.
*/
public static function sqlFunctionRand($seed = NULL) {
if (isset($seed)) {
mt_srand($seed);
}
return mt_rand() / mt_getrandmax();
}
/**
* SQLite compatibility implementation for the REGEXP SQL operator.
*
* The REGEXP operator is natively known, but not implemented by default.
*
* @see http://www.sqlite.org/lang_expr.html#regexp
*/
public static function sqlFunctionRegexp($pattern, $subject) {
// preg_quote() cannot be used here, since $pattern may contain reserved
// regular expression characters already (such as ^, $, etc). Therefore,
// use a rare character as PCRE delimiter.
$pattern = '#' . addcslashes($pattern, '#') . '#i';
return preg_match($pattern, $subject);
}
/**
* SQLite compatibility implementation for the LIKE BINARY SQL operator.
*
* SQLite supports case-sensitive LIKE operations through the
* 'case_sensitive_like' PRAGMA statement, but only for ASCII characters, so
* we have to provide our own implementation with UTF-8 support.
*
* @see https://sqlite.org/pragma.html#pragma_case_sensitive_like
* @see https://sqlite.org/lang_expr.html#like
*/
public static function sqlFunctionLikeBinary($pattern, $subject) {
// Replace the SQL LIKE wildcard meta-characters with the equivalent regular
// expression meta-characters and escape the delimiter that will be used for
// matching.
$pattern = str_replace(['%', '_'], ['.*?', '.'], preg_quote($pattern, '/'));
return preg_match('/^' . $pattern . '$/', $subject);
}
/**
* {@inheritdoc}
*/
public function queryRange($query, $from, $count, array $args = [], array $options = []) {
return $this->query($query . ' LIMIT ' . (int) $from . ', ' . (int) $count, $args, $options);
}
/**
* {@inheritdoc}
*/
public function queryTemporary($query, array $args = [], array $options = []) {
$tablename = 'db_temporary_' . uniqid();
$this->query('CREATE TEMPORARY TABLE ' . $tablename . ' AS ' . $query, $args, $options);
// Temporary tables always live in the temp database, which means that
// they cannot be fully qualified table names since they do not live
// in the main SQLite database. We provide the fully-qualified name
// ourselves to prevent Drupal from applying prefixes.
// @see https://www.sqlite.org/lang_createtable.html
return 'temp.' . $tablename;
}
/**
* {@inheritdoc}
*/
public function driver() {
return 'sqlite';
}
/**
* {@inheritdoc}
*/
public function databaseType() {
return 'sqlite';
}
/**
* Overrides \Drupal\Core\Database\Connection::createDatabase().
*
* @param string $database
* The name of the database to create.
*
* @throws \Drupal\Core\Database\DatabaseNotFoundException
*/
public function createDatabase($database) {
// Verify the database is writable.
$db_directory = new \SplFileInfo(dirname($database));
if (!$db_directory->isDir() && !\Drupal::service('file_system')->mkdir($db_directory->getPathName(), 0755, TRUE)) {
throw new DatabaseNotFoundException('Unable to create database directory ' . $db_directory->getPathName());
}
}
/**
* {@inheritdoc}
*/
public function mapConditionOperator($operator) {
return static::$sqliteConditionOperatorMap[$operator] ?? NULL;
}
/**
* {@inheritdoc}
*/
public function prepareStatement(string $query, array $options, bool $allow_row_count = FALSE): StatementInterface {
assert(!isset($options['return']), 'Passing "return" option to prepareStatement() has no effect. See https://www.drupal.org/node/3185520');
if (isset($options['fetch']) && is_int($options['fetch'])) {
@trigger_error("Passing the 'fetch' key as an integer to \$options in prepareStatement() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
}
try {
$query = $this->preprocessStatement($query, $options);
$statement = new Statement($this->connection, $this, $query, $options['pdo'] ?? [], $allow_row_count);
}
catch (\Exception $e) {
$this->exceptionHandler()->handleStatementException($e, $query, $options);
}
return $statement;
}
/**
* {@inheritdoc}
*/
public function getFullQualifiedTableName($table) {
$prefix = $this->getPrefix();
// Don't include the SQLite database file name as part of the table name.
return $prefix . $table;
}
/**
* {@inheritdoc}
*/
public static function createConnectionOptionsFromUrl($url, $root) {
if ($root !== NULL) {
@trigger_error("Passing the \$root value to " . __METHOD__ . "() is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3511287", E_USER_DEPRECATED);
}
$database = parent::createConnectionOptionsFromUrl($url, NULL);
// A SQLite database path with two leading slashes indicates a system path.
// Otherwise the path is relative to the Drupal root.
$url_components = parse_url($url);
if ($url_components['path'][0] === '/') {
$url_components['path'] = substr($url_components['path'], 1);
}
$database['database'] = $url_components['path'];
// User credentials and system port are irrelevant for SQLite.
unset(
$database['username'],
$database['password'],
$database['port']
);
return $database;
}
/**
* {@inheritdoc}
*/
public static function createUrlFromConnectionOptions(array $connection_options) {
if (!isset($connection_options['driver'], $connection_options['database'])) {
throw new \InvalidArgumentException("As a minimum, the connection options array must contain at least the 'driver' and 'database' keys");
}
$db_url = 'sqlite://localhost/' . $connection_options['database'] . '?module=sqlite';
if (isset($connection_options['prefix']) && $connection_options['prefix'] !== '') {
$db_url .= '#' . $connection_options['prefix'];
}
return $db_url;
}
/**
* {@inheritdoc}
*/
public function exceptionHandler() {
return new ExceptionHandler();
}
/**
* {@inheritdoc}
*/
public function select($table, $alias = NULL, array $options = []) {
return new Select($this, $table, $alias, $options);
}
/**
* {@inheritdoc}
*/
public function insert($table, array $options = []) {
return new Insert($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function upsert($table, array $options = []) {
return new Upsert($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function truncate($table, array $options = []) {
return new Truncate($this, $table, $options);
}
/**
* {@inheritdoc}
*/
public function schema() {
if (empty($this->schema)) {
$this->schema = new Schema($this);
}
return $this->schema;
}
/**
* {@inheritdoc}
*/
protected function driverTransactionManager(): TransactionManagerInterface {
return new TransactionManager($this);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Delete as QueryDelete;
@trigger_error('Extending from \Drupal\sqlite\Driver\Database\sqlite\Delete is deprecated in drupal:11.0.0 and is removed from drupal:12.0.0. Extend from the base class instead. See https://www.drupal.org/node/3256524', E_USER_DEPRECATED);
/**
* SQLite implementation of \Drupal\Core\Database\Query\Delete.
*/
class Delete extends QueryDelete {
}

View File

@ -0,0 +1,114 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\DatabaseExceptionWrapper;
use Drupal\Core\Database\Query\Insert as QueryInsert;
/**
* SQLite implementation of \Drupal\Core\Database\Query\Insert.
*
* We ignore all the default fields and use the clever SQLite syntax:
* INSERT INTO table DEFAULT VALUES
* for degenerated "default only" queries.
*/
class Insert extends QueryInsert {
/**
* {@inheritdoc}
*/
public function execute() {
if (!$this->preExecute()) {
return NULL;
}
// If we're selecting from a SelectQuery, finish building the query and
// pass it back, as any remaining options are irrelevant.
if (!empty($this->fromQuery)) {
// The SelectQuery may contain arguments, load and pass them through.
return $this->connection->query((string) $this, $this->fromQuery->getArguments(), $this->queryOptions);
}
// If there are any fields in the query, execute normal INSERT statements.
if (count($this->insertFields)) {
$stmt = $this->connection->prepareStatement((string) $this, $this->queryOptions);
if (count($this->insertValues) === 1) {
// Inserting a single row does not require a transaction to be atomic,
// and executes faster without a transaction wrapper.
$insert_values = $this->insertValues[0];
try {
$stmt->execute($insert_values, $this->queryOptions);
}
catch (\Exception $e) {
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, $insert_values, $this->queryOptions);
}
}
else {
// Inserting multiple rows requires a transaction to be atomic, and
// executes faster as a single transaction.
try {
$transaction = $this->connection->startTransaction();
}
catch (\PDOException $e) {
// $this->connection->exceptionHandler()->handleExecutionException()
// requires a $statement argument, so we cannot use that.
throw new DatabaseExceptionWrapper($e->getMessage(), 0, $e);
}
foreach ($this->insertValues as $insert_values) {
try {
$stmt->execute($insert_values, $this->queryOptions);
}
catch (\Exception $e) {
// One of the INSERTs failed, rollback the whole batch.
$transaction->rollBack();
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, $insert_values, $this->queryOptions);
}
}
}
// Re-initialize the values array so that we can re-use this query.
$this->insertValues = [];
}
// If there are no fields in the query, execute an INSERT statement that
// only populates default values.
else {
$stmt = $this->connection->prepareStatement("INSERT INTO {{$this->table}} DEFAULT VALUES", $this->queryOptions);
try {
$stmt->execute(NULL, $this->queryOptions);
}
catch (\Exception $e) {
$this->connection->exceptionHandler()->handleExecutionException($e, $stmt, [], $this->queryOptions);
}
}
return $this->connection->lastInsertId();
}
/**
* {@inheritdoc}
*/
public function __toString() {
// Create a sanitized comment string to prepend to the query.
$comments = $this->connection->makeComment($this->comments);
// Produce as many generic placeholders as necessary.
$placeholders = [];
if (!empty($this->insertFields)) {
$placeholders = array_fill(0, count($this->insertFields), '?');
}
$insert_fields = array_map(function ($field) {
return $this->connection->escapeField($field);
}, $this->insertFields);
// If we're selecting from a SelectQuery, finish building the query and
// pass it back, as any remaining options are irrelevant.
if (!empty($this->fromQuery)) {
$insert_fields_string = $insert_fields ? ' (' . implode(', ', $insert_fields) . ') ' : ' ';
return $comments . 'INSERT INTO {' . $this->table . '}' . $insert_fields_string . $this->fromQuery;
}
return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES (' . implode(', ', $placeholders) . ')';
}
}

View File

@ -0,0 +1,119 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite\Install;
use Drupal\Core\Database\Database;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\sqlite\Driver\Database\sqlite\Connection;
use Drupal\Core\Database\DatabaseNotFoundException;
use Drupal\Core\Database\Install\Tasks as InstallTasks;
/**
* Specifies installation tasks for SQLite databases.
*/
class Tasks extends InstallTasks {
use StringTranslationTrait;
/**
* Minimum required SQLite version.
*
* Use to build sqlite library with json1 option for JSON datatype support.
*
* @see https://www.sqlite.org/json1.html
*/
const SQLITE_MINIMUM_VERSION = '3.45';
/**
* {@inheritdoc}
*/
protected $pdoDriver = 'sqlite';
/**
* {@inheritdoc}
*/
public function name() {
return $this->t('SQLite');
}
/**
* {@inheritdoc}
*/
public function minimumVersion() {
return static::SQLITE_MINIMUM_VERSION;
}
/**
* {@inheritdoc}
*/
public function getFormOptions(array $database) {
$form = parent::getFormOptions($database);
// Remove the options that only apply to client/server style databases.
unset($form['username'], $form['password'], $form['advanced_options']['host'], $form['advanced_options']['port']);
// Make the text more accurate for SQLite.
$form['database']['#title'] = $this->t('Database file');
$form['database']['#description'] = $this->t('The absolute path to the file where @drupal data will be stored. This must be writable by the web server and should exist outside of the web root.', ['@drupal' => drupal_install_profile_distribution_name()]);
$default_database = \Drupal::getContainer()->getParameter('site.path') . '/files/.ht.sqlite';
$form['database']['#default_value'] = empty($database['database']) ? $default_database : $database['database'];
return $form;
}
/**
* {@inheritdoc}
*/
protected function connect() {
try {
// This doesn't actually test the connection.
Database::setActiveConnection();
// Now actually do a check.
Database::getConnection();
$this->pass('Drupal can CONNECT to the database ok.');
}
catch (\Exception $e) {
// Attempt to create the database if it is not found.
if ($e->getCode() == Connection::DATABASE_NOT_FOUND) {
// Remove the database string from connection info.
$connection_info = Database::getConnectionInfo();
$database = $connection_info['default']['database'];
// We cannot use \Drupal::service('file_system')->getTempDirectory()
// here because we haven't yet successfully connected to the database.
$connection_info['default']['database'] = \Drupal::service('file_system')->tempnam(sys_get_temp_dir(), 'sqlite');
// In order to change the Database::$databaseInfo array, need to remove
// the active connection, then re-add it with the new info.
Database::removeConnection('default');
Database::addConnectionInfo('default', 'default', $connection_info['default']);
try {
Database::getConnection()->createDatabase($database);
Database::closeConnection();
// Now, restore the database config.
Database::removeConnection('default');
$connection_info['default']['database'] = $database;
Database::addConnectionInfo('default', 'default', $connection_info['default']);
// Check the database connection.
Database::getConnection();
$this->pass('Drupal can CONNECT to the database ok.');
}
catch (DatabaseNotFoundException $e) {
// Still no dice; probably a permission issue. Raise the error to the
// installer.
$this->fail($this->t('Failed to open or create database file %database. The database engine reports the following message when attempting to create the database: %error.', ['%database' => $database, '%error' => $e->getMessage()]));
}
}
else {
// Database connection failed for some other reason than a non-existent
// database.
$this->fail($this->t('Failed to connect to database. The database engine reports the following message: %error.<ul><li>Does the database file exist?</li><li>Does web server have permission to write to the database file?</li>Does the web server have permission to write to the directory the database file should be created in?</li></ul>', ['%error' => $e->getMessage()]));
return FALSE;
}
}
return TRUE;
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Merge as QueryMerge;
@trigger_error('Extending from \Drupal\sqlite\Driver\Database\sqlite\Merge is deprecated in drupal:11.0.0 and is removed from drupal:12.0.0. Extend from the base class instead. See https://www.drupal.org/node/3256524', E_USER_DEPRECATED);
/**
* SQLite implementation of \Drupal\Core\Database\Query\Merge.
*/
class Merge extends QueryMerge {
}

View File

@ -0,0 +1,53 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
/**
* SQLite-specific implementation of a PDO connection.
*
* SQLite does not implement row locks, so when it acquires a lock, it locks
* the entire database. To improve performance, by default SQLite tries to
* defer acquiring a write lock until the first write operation of a
* transaction rather than when the transaction is started. Unfortunately, this
* seems to be incompatible with how Drupal uses transactions, and frequently
* leads to deadlocks.
*
* Therefore, this class overrides \PDO to begin transactions with a
* BEGIN IMMEDIATE TRANSACTION statement, for which SQLite acquires the write
* lock immediately. This can incur some performance cost in a high concurrency
* environment: it adds approximately 5% to the time it takes to execute Drupal
* core's entire test suite on DrupalCI, and it potentially could add more in a
* higher concurrency environment. However, under high enough concurrency of a
* Drupal application, SQLite isn't the best choice anyway, and a database
* engine that implements row locking, such as MySQL or PostgreSQL, is more
* suitable.
*
* Because of https://bugs.php.net/42766 we have to create such a transaction
* manually which means we must also override commit() and rollback().
*
* @see https://www.drupal.org/project/drupal/issues/1120020
*/
class PDOConnection extends \PDO {
/**
* {@inheritdoc}
*/
public function beginTransaction(): bool {
return $this->exec('BEGIN IMMEDIATE TRANSACTION') !== FALSE;
}
/**
* {@inheritdoc}
*/
public function commit(): bool {
return $this->exec('COMMIT') !== FALSE;
}
/**
* {@inheritdoc}
*/
public function rollBack(): bool {
return $this->exec('ROLLBACK') !== FALSE;
}
}

View File

@ -0,0 +1,836 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\SchemaObjectExistsException;
use Drupal\Core\Database\SchemaObjectDoesNotExistException;
use Drupal\Core\Database\Schema as DatabaseSchema;
// cspell:ignore autoincrement autoindex
/**
* @ingroup schemaapi
* @{
*/
/**
* SQLite implementation of \Drupal\Core\Database\Schema.
*/
class Schema extends DatabaseSchema {
/**
* Override DatabaseSchema::$defaultSchema.
*
* @var string
*/
protected $defaultSchema = 'main';
/**
* {@inheritdoc}
*/
public function tableExists($table, $add_prefix = TRUE) {
$info = $this->getPrefixInfo($table, $add_prefix);
// Don't use {} around sqlite_master table.
return (bool) $this->connection->query('SELECT 1 FROM [' . $info['schema'] . '].sqlite_master WHERE type = :type AND name = :name', [':type' => 'table', ':name' => $info['table']])->fetchField();
}
/**
* {@inheritdoc}
*/
public function fieldExists($table, $column) {
$schema = $this->introspectSchema($table);
return !empty($schema['fields'][$column]);
}
/**
* {@inheritdoc}
*/
public function createTableSql($name, $table) {
if (!empty($table['primary key']) && is_array($table['primary key'])) {
$this->ensureNotNullPrimaryKey($table['primary key'], $table['fields']);
}
$sql = [];
$sql[] = "CREATE TABLE {" . $name . "} (\n" . $this->createColumnsSql($name, $table) . "\n)\n";
return array_merge($sql, $this->createIndexSql($name, $table));
}
/**
* Build the SQL expression for indexes.
*/
protected function createIndexSql($tablename, $schema) {
$sql = [];
$info = $this->getPrefixInfo($tablename);
if (!empty($schema['unique keys'])) {
foreach ($schema['unique keys'] as $key => $fields) {
$sql[] = 'CREATE UNIQUE INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $key . '] ON [' . $info['table'] . '] (' . $this->createKeySql($fields) . ")\n";
}
}
if (!empty($schema['indexes'])) {
foreach ($schema['indexes'] as $key => $fields) {
$sql[] = 'CREATE INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $key . '] ON [' . $info['table'] . '] (' . $this->createKeySql($fields) . ")\n";
}
}
return $sql;
}
/**
* Build the SQL expression for creating columns.
*/
protected function createColumnsSql($tablename, $schema) {
$sql_array = [];
// Add the SQL statement for each field.
foreach ($schema['fields'] as $name => $field) {
if (isset($field['type']) && $field['type'] == 'serial') {
if (isset($schema['primary key']) && ($key = array_search($name, $schema['primary key'])) !== FALSE) {
unset($schema['primary key'][$key]);
}
}
$sql_array[] = $this->createFieldSql($name, $this->processField($field));
}
// Process keys.
if (!empty($schema['primary key'])) {
$sql_array[] = " PRIMARY KEY (" . $this->createKeySql($schema['primary key']) . ")";
}
return implode(", \n", $sql_array);
}
/**
* Build the SQL expression for keys.
*/
protected function createKeySql($fields) {
$return = [];
foreach ($fields as $field) {
if (is_array($field)) {
$return[] = '[' . $field[0] . ']';
}
else {
$return[] = '[' . $field . ']';
}
}
return implode(', ', $return);
}
/**
* Set database-engine specific properties for a field.
*
* @param array $field
* A field description array, as specified in the schema documentation.
*/
protected function processField($field) {
if (!isset($field['size'])) {
$field['size'] = 'normal';
}
// Set the correct database-engine specific datatype.
// In case one is already provided, force it to uppercase.
if (isset($field['sqlite_type'])) {
$field['sqlite_type'] = mb_strtoupper($field['sqlite_type']);
}
else {
$map = $this->getFieldTypeMap();
$field['sqlite_type'] = $map[$field['type'] . ':' . $field['size']];
// Numeric fields with a specified scale have to be stored as floats.
if ($field['sqlite_type'] === 'NUMERIC' && isset($field['scale'])) {
$field['sqlite_type'] = 'FLOAT';
}
}
if (isset($field['type']) && $field['type'] == 'serial') {
$field['auto_increment'] = TRUE;
}
return $field;
}
/**
* Create an SQL string for a field to be used in table create or alter.
*
* Before passing a field out of a schema definition into this function it has
* to be processed by self::processField().
*
* @param string $name
* Name of the field.
* @param array $spec
* The field specification, as per the schema data structure format.
*/
protected function createFieldSql($name, $spec) {
$name = $this->connection->escapeField($name);
if (!empty($spec['auto_increment'])) {
$sql = $name . " INTEGER PRIMARY KEY AUTOINCREMENT";
if (!empty($spec['unsigned'])) {
$sql .= ' CHECK (' . $name . '>= 0)';
}
}
else {
$sql = $name . ' ' . $spec['sqlite_type'];
if (in_array($spec['sqlite_type'], ['VARCHAR', 'TEXT'])) {
if (isset($spec['length'])) {
$sql .= '(' . $spec['length'] . ')';
}
if (isset($spec['binary']) && $spec['binary'] === FALSE) {
$sql .= ' COLLATE NOCASE_UTF8';
}
}
if (isset($spec['not null'])) {
if ($spec['not null']) {
$sql .= ' NOT NULL';
}
else {
$sql .= ' NULL';
}
}
if (!empty($spec['unsigned'])) {
$sql .= ' CHECK (' . $name . '>= 0)';
}
if (isset($spec['default'])) {
if (is_string($spec['default'])) {
$spec['default'] = $this->connection->quote($spec['default']);
}
$sql .= ' DEFAULT ' . $spec['default'];
}
if (empty($spec['not null']) && !isset($spec['default'])) {
$sql .= ' DEFAULT NULL';
}
}
return $sql;
}
/**
* {@inheritdoc}
*/
public function getFieldTypeMap() {
// Put :normal last so it gets preserved by array_flip. This makes
// it much easier for modules (such as schema.module) to map
// database types back into schema types.
// $map does not use drupal_static as its value never changes.
static $map = [
'varchar_ascii:normal' => 'VARCHAR',
'varchar:normal' => 'VARCHAR',
'char:normal' => 'CHAR',
'text:tiny' => 'TEXT',
'text:small' => 'TEXT',
'text:medium' => 'TEXT',
'text:big' => 'TEXT',
'text:normal' => 'TEXT',
'serial:tiny' => 'INTEGER',
'serial:small' => 'INTEGER',
'serial:medium' => 'INTEGER',
'serial:big' => 'INTEGER',
'serial:normal' => 'INTEGER',
'int:tiny' => 'INTEGER',
'int:small' => 'INTEGER',
'int:medium' => 'INTEGER',
'int:big' => 'INTEGER',
'int:normal' => 'INTEGER',
'float:tiny' => 'FLOAT',
'float:small' => 'FLOAT',
'float:medium' => 'FLOAT',
'float:big' => 'FLOAT',
'float:normal' => 'FLOAT',
'numeric:normal' => 'NUMERIC',
'blob:big' => 'BLOB',
'blob:normal' => 'BLOB',
// Only the SQLite driver has this field map to due to a fatal error
// error caused by this driver's schema on table introspection.
// @todo Add support to all drivers in https://drupal.org/i/3343634
'json:normal' => 'JSON',
];
return $map;
}
/**
* {@inheritdoc}
*/
public function renameTable($table, $new_name) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot rename '$table' to '$new_name': table '$table' doesn't exist.");
}
if ($this->tableExists($new_name)) {
throw new SchemaObjectExistsException("Cannot rename '$table' to '$new_name': table '$new_name' already exists.");
}
$schema = $this->introspectSchema($table);
// SQLite doesn't allow you to rename tables outside of the current
// database. So the syntax '... RENAME TO database.table' would fail.
// So we must determine the full table name here rather than surrounding
// the table with curly braces in case the db_prefix contains a reference
// to a database outside of our existing database.
$info = $this->getPrefixInfo($new_name);
$this->executeDdlStatement('ALTER TABLE {' . $table . '} RENAME TO [' . $info['table'] . ']');
// Drop the indexes, there is no RENAME INDEX command in SQLite.
if (!empty($schema['unique keys'])) {
foreach ($schema['unique keys'] as $key => $fields) {
$this->dropIndex($table, $key);
}
}
if (!empty($schema['indexes'])) {
foreach ($schema['indexes'] as $index => $fields) {
$this->dropIndex($table, $index);
}
}
// Recreate the indexes.
$statements = $this->createIndexSql($new_name, $schema);
foreach ($statements as $statement) {
$this->executeDdlStatement($statement);
}
}
/**
* {@inheritdoc}
*/
public function dropTable($table) {
if (!$this->tableExists($table)) {
return FALSE;
}
$this->connection->tableDropped = TRUE;
$this->executeDdlStatement('DROP TABLE {' . $table . '}');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function addField($table, $field, $specification, $keys_new = []) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add field '$table.$field': table doesn't exist.");
}
if ($this->fieldExists($table, $field)) {
throw new SchemaObjectExistsException("Cannot add field '$table.$field': field already exists.");
}
if (isset($keys_new['primary key']) && in_array($field, $keys_new['primary key'], TRUE)) {
$this->ensureNotNullPrimaryKey($keys_new['primary key'], [$field => $specification]);
}
// SQLite doesn't have a full-featured ALTER TABLE statement. It only
// supports adding new fields to a table, in some simple cases. In most
// cases, we have to create a new table and copy the data over.
if (empty($keys_new) && (empty($specification['not null']) || isset($specification['default']))) {
// When we don't have to create new keys and we are not creating a NOT
// NULL column without a default value, we can use the quicker version.
$query = 'ALTER TABLE {' . $table . '} ADD ' . $this->createFieldSql($field, $this->processField($specification));
$this->executeDdlStatement($query);
// Apply the initial value if set.
if (isset($specification['initial_from_field'])) {
if (isset($specification['initial'])) {
$expression = 'COALESCE(' . $specification['initial_from_field'] . ', :default_initial_value)';
$arguments = [':default_initial_value' => $specification['initial']];
}
else {
$expression = $specification['initial_from_field'];
$arguments = [];
}
$this->connection->update($table)
->expression($field, $expression, $arguments)
->execute();
}
elseif (isset($specification['initial'])) {
$this->connection->update($table)
->fields([$field => $specification['initial']])
->execute();
}
}
else {
// We cannot add the field directly. Use the slower table alteration
// method, starting from the old schema.
$old_schema = $this->introspectSchema($table);
$new_schema = $old_schema;
// Add the new field.
$new_schema['fields'][$field] = $specification;
// Build the mapping between the old fields and the new fields.
$mapping = [];
if (isset($specification['initial_from_field'])) {
// If we have an initial value, copy it over.
if (isset($specification['initial'])) {
$expression = 'COALESCE(' . $specification['initial_from_field'] . ', :default_initial_value)';
$arguments = [':default_initial_value' => $specification['initial']];
}
else {
$expression = $specification['initial_from_field'];
$arguments = [];
}
$mapping[$field] = [
'expression' => $expression,
'arguments' => $arguments,
];
}
elseif (isset($specification['initial'])) {
// If we have an initial value, copy it over.
$mapping[$field] = [
'expression' => ':new_field_initial',
'arguments' => [':new_field_initial' => $specification['initial']],
];
}
else {
// Else use the default of the field.
$mapping[$field] = NULL;
}
// Add the new indexes.
$new_schema = array_merge($new_schema, $keys_new);
$this->alterTable($table, $old_schema, $new_schema, $mapping);
}
}
/**
* Create a table with a new schema containing the old content.
*
* As SQLite does not support ALTER TABLE (with a few exceptions) it is
* necessary to create a new table and copy over the old content.
*
* @param string $table
* Name of the table to be altered.
* @param array $old_schema
* The old schema array for the table.
* @param array $new_schema
* The new schema array for the table.
* @param array $mapping
* An optional mapping between the fields of the old specification and the
* fields of the new specification. An associative array, whose keys are
* the fields of the new table, and values can take two possible forms:
* - a simple string, which is interpreted as the name of a field of the
* old table,
* - an associative array with two keys 'expression' and 'arguments',
* that will be used as an expression field.
*/
protected function alterTable($table, $old_schema, $new_schema, array $mapping = []) {
$i = 0;
do {
$new_table = $table . '_' . $i++;
} while ($this->tableExists($new_table));
$this->createTable($new_table, $new_schema);
// Build a SQL query to migrate the data from the old table to the new.
$select = $this->connection->select($table);
// Complete the mapping.
$possible_keys = array_keys($new_schema['fields']);
$mapping += array_combine($possible_keys, $possible_keys);
// Now add the fields.
foreach ($mapping as $field_alias => $field_source) {
// Just ignore this field (ie. use its default value).
if (!isset($field_source)) {
continue;
}
if (is_array($field_source)) {
$select->addExpression($field_source['expression'], $field_alias, $field_source['arguments']);
}
else {
$select->addField($table, $field_source, $field_alias);
}
}
// Execute the data migration query.
$this->connection->insert($new_table)
->from($select)
->execute();
$old_count = $this->connection->query('SELECT COUNT(*) FROM {' . $table . '}')->fetchField();
$new_count = $this->connection->query('SELECT COUNT(*) FROM {' . $new_table . '}')->fetchField();
if ($old_count == $new_count) {
$this->dropTable($table);
$this->renameTable($new_table, $table);
}
}
/**
* Find out the schema of a table.
*
* This function uses introspection methods provided by the database to
* create a schema array. This is useful, for example, during update when
* the old schema is not available.
*
* @param string $table
* Name of the table.
*
* @return array
* An array representing the schema.
*
* @throws \Exception
* If a column of the table could not be parsed.
*/
protected function introspectSchema($table) {
$mapped_fields = array_flip($this->getFieldTypeMap());
$schema = [
'fields' => [],
'primary key' => [],
'unique keys' => [],
'indexes' => [],
];
$info = $this->getPrefixInfo($table);
$result = $this->connection->query('PRAGMA [' . $info['schema'] . '].table_info([' . $info['table'] . '])');
foreach ($result as $row) {
if (preg_match('/^([^(]+)\((.*)\)$/', $row->type, $matches)) {
$type = $matches[1];
$length = $matches[2];
}
else {
$type = $row->type;
$length = NULL;
}
if (isset($mapped_fields[$type])) {
[$type, $size] = explode(':', $mapped_fields[$type]);
$schema['fields'][$row->name] = [
'type' => $type,
'size' => $size,
'not null' => !empty($row->notnull) || $row->pk !== "0",
];
if ($length) {
$schema['fields'][$row->name]['length'] = $length;
}
// Convert the default into a properly typed value.
if ($row->dflt_value === 'NULL') {
$schema['fields'][$row->name]['default'] = NULL;
}
elseif (is_string($row->dflt_value) && $row->dflt_value[0] === '\'') {
// Remove the wrapping single quotes. And replace duplicate single
// quotes with a single quote.
$schema['fields'][$row->name]['default'] = str_replace("''", "'", substr($row->dflt_value, 1, -1));
}
elseif (is_numeric($row->dflt_value)) {
// Adding 0 to a string will cause PHP to convert it to a float or
// an integer depending on what the string is. For example:
// - '1' + 0 = 1
// - '1.0' + 0 = 1.0
$schema['fields'][$row->name]['default'] = $row->dflt_value + 0;
}
else {
$schema['fields'][$row->name]['default'] = $row->dflt_value;
}
// $row->pk contains a number that reflects the primary key order. We
// use that as the key and sort (by key) below to return the primary key
// in the same order that it is stored in.
if ($row->pk) {
$schema['primary key'][$row->pk] = $row->name;
}
}
else {
throw new \Exception("Unable to parse the column type " . $row->type);
}
}
ksort($schema['primary key']);
// Re-key the array because $row->pk starts counting at 1.
$schema['primary key'] = array_values($schema['primary key']);
$indexes = [];
$result = $this->connection->query('PRAGMA [' . $info['schema'] . '].index_list([' . $info['table'] . '])');
foreach ($result as $row) {
if (!str_starts_with($row->name, 'sqlite_autoindex_')) {
$indexes[] = [
'schema_key' => $row->unique ? 'unique keys' : 'indexes',
'name' => $row->name,
];
}
}
foreach ($indexes as $index) {
$name = $index['name'];
// Get index name without prefix.
$index_name = substr($name, strlen($info['table']) + 1);
$result = $this->connection->query('PRAGMA [' . $info['schema'] . '].index_info([' . $name . '])');
foreach ($result as $row) {
$schema[$index['schema_key']][$index_name][] = $row->name;
}
}
return $schema;
}
/**
* {@inheritdoc}
*/
public function dropField($table, $field) {
if (!$this->fieldExists($table, $field)) {
return FALSE;
}
$old_schema = $this->introspectSchema($table);
$new_schema = $old_schema;
unset($new_schema['fields'][$field]);
// Drop the primary key if the field to drop is part of it. This is
// consistent with the behavior on PostgreSQL.
// @see \Drupal\mysql\Driver\Database\mysql\Schema::dropField()
if (isset($new_schema['primary key']) && in_array($field, $new_schema['primary key'], TRUE)) {
unset($new_schema['primary key']);
}
// Handle possible index changes.
foreach ($new_schema['indexes'] as $index => $fields) {
foreach ($fields as $key => $field_name) {
if ($field_name == $field) {
unset($new_schema['indexes'][$index][$key]);
}
}
// If this index has no more fields then remove it.
if (empty($new_schema['indexes'][$index])) {
unset($new_schema['indexes'][$index]);
}
}
$this->alterTable($table, $old_schema, $new_schema);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function changeField($table, $field, $field_new, $spec, $keys_new = []) {
if (!$this->fieldExists($table, $field)) {
throw new SchemaObjectDoesNotExistException("Cannot change the definition of field '$table.$field': field doesn't exist.");
}
if (($field != $field_new) && $this->fieldExists($table, $field_new)) {
throw new SchemaObjectExistsException("Cannot rename field '$table.$field' to '$field_new': target field already exists.");
}
if (isset($keys_new['primary key']) && in_array($field_new, $keys_new['primary key'], TRUE)) {
$this->ensureNotNullPrimaryKey($keys_new['primary key'], [$field_new => $spec]);
}
$old_schema = $this->introspectSchema($table);
$new_schema = $old_schema;
// Map the old field to the new field.
if ($field != $field_new) {
$mapping[$field_new] = $field;
}
else {
$mapping = [];
}
// Remove the previous definition and swap in the new one.
unset($new_schema['fields'][$field]);
$new_schema['fields'][$field_new] = $spec;
// Map the former indexes to the new column name.
$new_schema['primary key'] = $this->mapKeyDefinition($new_schema['primary key'], $mapping);
foreach (['unique keys', 'indexes'] as $k) {
foreach ($new_schema[$k] as &$key_definition) {
$key_definition = $this->mapKeyDefinition($key_definition, $mapping);
}
}
// Add in the keys from $keys_new.
if (isset($keys_new['primary key'])) {
$new_schema['primary key'] = $keys_new['primary key'];
}
foreach (['unique keys', 'indexes'] as $k) {
if (!empty($keys_new[$k])) {
$new_schema[$k] = $keys_new[$k] + $new_schema[$k];
}
}
$this->alterTable($table, $old_schema, $new_schema, $mapping);
}
/**
* Renames columns in an index definition according to a new mapping.
*
* @param array $key_definition
* The key definition.
* @param array $mapping
* The new mapping.
*/
protected function mapKeyDefinition(array $key_definition, array $mapping) {
foreach ($key_definition as &$field) {
// The key definition can be an array such as [$field, $length].
if (is_array($field)) {
$field = &$field[0];
}
$mapped_field = array_search($field, $mapping, TRUE);
if ($mapped_field !== FALSE) {
$field = $mapped_field;
}
}
return $key_definition;
}
/**
* {@inheritdoc}
*/
public function addIndex($table, $name, $fields, array $spec) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add index '$name' to table '$table': table doesn't exist.");
}
if ($this->indexExists($table, $name)) {
throw new SchemaObjectExistsException("Cannot add index '$name' to table '$table': index already exists.");
}
$schema['indexes'][$name] = $fields;
$statements = $this->createIndexSql($table, $schema);
foreach ($statements as $statement) {
$this->executeDdlStatement($statement);
}
}
/**
* {@inheritdoc}
*/
public function indexExists($table, $name) {
$info = $this->getPrefixInfo($table);
return $this->connection->query('PRAGMA [' . $info['schema'] . '].index_info([' . $info['table'] . '_' . $name . '])')->fetchField() != '';
}
/**
* {@inheritdoc}
*/
public function dropIndex($table, $name) {
if (!$this->indexExists($table, $name)) {
return FALSE;
}
$info = $this->getPrefixInfo($table);
$this->executeDdlStatement('DROP INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $name . ']');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function addUniqueKey($table, $name, $fields) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add unique key '$name' to table '$table': table doesn't exist.");
}
if ($this->indexExists($table, $name)) {
throw new SchemaObjectExistsException("Cannot add unique key '$name' to table '$table': unique key already exists.");
}
$schema['unique keys'][$name] = $fields;
$statements = $this->createIndexSql($table, $schema);
foreach ($statements as $statement) {
$this->executeDdlStatement($statement);
}
}
/**
* {@inheritdoc}
*/
public function dropUniqueKey($table, $name) {
if (!$this->indexExists($table, $name)) {
return FALSE;
}
$info = $this->getPrefixInfo($table);
$this->executeDdlStatement('DROP INDEX [' . $info['schema'] . '].[' . $info['table'] . '_' . $name . ']');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function addPrimaryKey($table, $fields) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("Cannot add primary key to table '$table': table doesn't exist.");
}
$old_schema = $this->introspectSchema($table);
$new_schema = $old_schema;
if (!empty($new_schema['primary key'])) {
throw new SchemaObjectExistsException("Cannot add primary key to table '$table': primary key already exists.");
}
$new_schema['primary key'] = $fields;
$this->ensureNotNullPrimaryKey($new_schema['primary key'], $new_schema['fields']);
$this->alterTable($table, $old_schema, $new_schema);
}
/**
* {@inheritdoc}
*/
public function dropPrimaryKey($table) {
$old_schema = $this->introspectSchema($table);
$new_schema = $old_schema;
if (empty($new_schema['primary key'])) {
return FALSE;
}
unset($new_schema['primary key']);
$this->alterTable($table, $old_schema, $new_schema);
return TRUE;
}
/**
* {@inheritdoc}
*/
protected function findPrimaryKeyColumns($table) {
if (!$this->tableExists($table)) {
return FALSE;
}
$schema = $this->introspectSchema($table);
return $schema['primary key'];
}
/**
* {@inheritdoc}
*/
protected function introspectIndexSchema($table) {
if (!$this->tableExists($table)) {
throw new SchemaObjectDoesNotExistException("The table $table doesn't exist.");
}
$schema = $this->introspectSchema($table);
unset($schema['fields']);
return $schema;
}
/**
* {@inheritdoc}
*/
public function findTables($table_expression) {
$tables = [];
// The SQLite implementation doesn't need to use the same filtering strategy
// as the parent one because individually prefixed tables live in their own
// schema (database), which means that neither the main database nor any
// attached one will contain a prefixed table name, so we just need to loop
// over all known schemas and filter by the user-supplied table expression.
$attached_dbs = $this->connection->getAttachedDatabases();
foreach ($attached_dbs as $schema) {
// Can't use query placeholders for the schema because the query would
// have to be :prefixsqlite_master, which does not work. We also need to
// ignore the internal SQLite tables.
$result = $this->connection->query("SELECT name FROM [" . $schema . "].sqlite_master WHERE type = :type AND name LIKE :table_name AND name NOT LIKE :pattern", [
':type' => 'table',
':table_name' => $table_expression,
':pattern' => 'sqlite_%',
]);
$tables += $result->fetchAllKeyed(0, 0);
}
return $tables;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Select as QuerySelect;
/**
* SQLite implementation of \Drupal\Core\Database\Query\Select.
*/
class Select extends QuerySelect {
/**
* {@inheritdoc}
*/
public function forUpdate($set = TRUE) {
// SQLite does not support FOR UPDATE so nothing to do.
return $this;
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\StatementInterface;
use Drupal\Core\Database\StatementPrefetchIterator;
/**
* SQLite implementation of \Drupal\Core\Database\Statement.
*
* The PDO SQLite driver only closes SELECT statements when the PDOStatement
* destructor is called and SQLite does not allow data change (INSERT,
* UPDATE etc) on a table which has open SELECT statements. This is a
* user-space mock of PDOStatement that buffers all the data and doesn't
* have those limitations.
*/
class Statement extends StatementPrefetchIterator implements StatementInterface {
/**
* {@inheritdoc}
*
* The PDO SQLite layer doesn't replace numeric placeholders in queries
* correctly, and this makes numeric expressions (such as COUNT(*) >= :count)
* fail. We replace numeric placeholders in the query ourselves to work
* around this bug.
*
* See http://bugs.php.net/bug.php?id=45259 for more details.
*/
protected function getStatement(string $query, ?array &$args = []): object {
if (is_array($args) && !empty($args)) {
// Check if $args is a simple numeric array.
if (range(0, count($args) - 1) === array_keys($args)) {
// In that case, we have unnamed placeholders.
$count = 0;
$new_args = [];
foreach ($args as $value) {
if (is_float($value) || is_int($value)) {
if (is_float($value)) {
// Force the conversion to float so as not to loose precision
// in the automatic cast.
$value = sprintf('%F', $value);
}
$query = substr_replace($query, $value, strpos($query, '?'), 1);
}
else {
$placeholder = ':db_statement_placeholder_' . $count++;
$query = substr_replace($query, $placeholder, strpos($query, '?'), 1);
$new_args[$placeholder] = $value;
}
}
$args = $new_args;
}
else {
// Else, this is using named placeholders.
foreach ($args as $placeholder => $value) {
if (is_float($value) || is_int($value)) {
if (is_float($value)) {
// Force the conversion to float so as not to loose precision
// in the automatic cast.
$value = sprintf('%F', $value);
}
// We will remove this placeholder from the query as PDO throws an
// exception if the number of placeholders in the query and the
// arguments does not match.
unset($args[$placeholder]);
// PDO allows placeholders to not be prefixed by a colon. See
// http://marc.info/?l=php-internals&m=111234321827149&w=2 for
// more.
if ($placeholder[0] != ':') {
$placeholder = ":$placeholder";
}
// When replacing the placeholders, make sure we search for the
// exact placeholder. For example, if searching for
// ':db_placeholder_1', do not replace ':db_placeholder_11'.
$query = preg_replace('/' . preg_quote($placeholder, NULL) . '\b/', $value, $query);
}
}
}
}
return $this->clientConnection->prepare($query);
}
/**
* {@inheritdoc}
*/
public function execute($args = [], $options = []) {
if (isset($options['fetch']) && is_int($options['fetch'])) {
@trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED);
}
try {
$return = parent::execute($args, $options);
}
catch (\PDOException $e) {
// The database schema might be changed by another process in between the
// time that the statement was prepared and the time the statement was run
// (e.g. usually happens when running tests). In this case, we need to
// re-run the query.
// @see http://www.sqlite.org/faq.html#q15
// @see http://www.sqlite.org/rescode.html#schema
if (!empty($e->errorInfo[1]) && $e->errorInfo[1] === 17) {
// The schema has changed. SQLite specifies that we must resend the
// query.
$return = parent::execute($args, $options);
}
else {
// Rethrow the exception.
throw $e;
}
}
return $return;
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Transaction\ClientConnectionTransactionState;
use Drupal\Core\Database\Transaction\TransactionManagerBase;
/**
* SQLite implementation of TransactionManagerInterface.
*/
class TransactionManager extends TransactionManagerBase {
/**
* {@inheritdoc}
*/
protected function beginClientTransaction(): bool {
return $this->connection->getClientConnection()->beginTransaction();
}
/**
* {@inheritdoc}
*/
protected function rollbackClientTransaction(): bool {
$clientRollback = $this->connection->getClientConnection()->rollBack();
$this->setConnectionTransactionState($clientRollback ?
ClientConnectionTransactionState::RolledBack :
ClientConnectionTransactionState::RollbackFailed
);
return $clientRollback;
}
/**
* {@inheritdoc}
*/
protected function commitClientTransaction(): bool {
$clientCommit = $this->connection->getClientConnection()->commit();
$this->setConnectionTransactionState($clientCommit ?
ClientConnectionTransactionState::Committed :
ClientConnectionTransactionState::CommitFailed
);
return $clientCommit;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Truncate as QueryTruncate;
/**
* SQLite implementation of \Drupal\Core\Database\Query\Truncate.
*
* SQLite doesn't support TRUNCATE, but a DELETE query with no condition has
* exactly the effect (it is implemented by DROPing the table).
*/
class Truncate extends QueryTruncate {
/**
* {@inheritdoc}
*/
public function __toString() {
// Create a sanitized comment string to prepend to the query.
$comments = $this->connection->makeComment($this->comments);
return $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '} ';
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Update as QueryUpdate;
@trigger_error('Extending from \Drupal\sqlite\Driver\Database\sqlite\Update is deprecated in drupal:11.0.0 and is removed from drupal:12.0.0. Extend from the base class instead. See https://www.drupal.org/node/3256524', E_USER_DEPRECATED);
/**
* SQLite implementation of \Drupal\Core\Database\Query\Update.
*/
class Update extends QueryUpdate {
}

View File

@ -0,0 +1,47 @@
<?php
namespace Drupal\sqlite\Driver\Database\sqlite;
use Drupal\Core\Database\Query\Upsert as QueryUpsert;
/**
* SQLite implementation of \Drupal\Core\Database\Query\Upsert.
*
* @see https://www.sqlite.org/lang_UPSERT.html
*/
class Upsert extends QueryUpsert {
/**
* {@inheritdoc}
*/
public function __toString() {
// Create a sanitized comment string to prepend to the query.
$comments = $this->connection->makeComment($this->comments);
// Default fields are always placed first for consistency.
$insert_fields = array_merge($this->defaultFields, $this->insertFields);
$insert_fields = array_map(function ($field) {
return $this->connection->escapeField($field);
}, $insert_fields);
$query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
$values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
$query .= implode(', ', $values);
// Updating the unique / primary key is not necessary.
unset($insert_fields[$this->key]);
$update = [];
foreach ($insert_fields as $field) {
// The "excluded." prefix causes the field to refer to the value for field
// that would have been inserted had there been no conflict.
$update[] = "$field = EXCLUDED.$field";
}
$query .= ' ON CONFLICT (' . $this->connection->escapeField($this->key) . ') DO UPDATE SET ' . implode(', ', $update);
return $query;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace Drupal\sqlite\Hook;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Hook implementations for sqlite.
*/
class SqliteHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.sqlite':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The SQLite module provides the connection between Drupal and a SQLite database. For more information, see the <a href=":sqlite">online documentation for the SQLite module</a>.', [
':sqlite' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/sqlite-module',
]) . '</p>';
return $output;
}
return NULL;
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for sqlite.
*
* @group sqlite
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\DriverSpecificKernelTestBase;
/**
* Tests exceptions thrown by queries.
*
* @group Database
*/
class DatabaseExceptionWrapperTest extends DriverSpecificKernelTestBase {
/**
* Tests Connection::prepareStatement exception on execution.
*/
public function testPrepareStatementFailOnExecution(): void {
$this->expectException(\PDOException::class);
$stmt = $this->connection->prepareStatement('bananas', []);
$stmt->execute();
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite\Plugin\views;
use Drupal\Tests\views\Kernel\Plugin\CastedIntFieldJoinTestBase;
/**
* Tests SQLite specific cast handling.
*
* @group Database
*/
class SqliteCastedIntFieldJoinTest extends CastedIntFieldJoinTestBase {
/**
* The db type that should be used for casting fields as integers.
*/
protected string $castingType = 'INTEGER';
}

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\DriverSpecificSchemaTestBase;
/**
* Tests schema API for the SQLite driver.
*
* @group Database
*/
class SchemaTest extends DriverSpecificSchemaTestBase {
/**
* {@inheritdoc}
*/
public function checkSchemaComment(string $description, string $table, ?string $column = NULL): void {
// The sqlite driver schema does not support fetching table/column
// comments.
}
/**
* {@inheritdoc}
*/
protected function tryInsertExpectsIntegrityConstraintViolationException(string $tableName): void {
// Sqlite does not throw an IntegrityConstraintViolationException here.
}
/**
* {@inheritdoc}
*/
public function testTableWithSpecificDataType(): void {
$table_specification = [
'description' => 'Schema table description.',
'fields' => [
'timestamp' => [
'sqlite_type' => 'datetime',
'not null' => FALSE,
'default' => NULL,
],
],
];
$this->schema->createTable('test_timestamp', $table_specification);
$this->assertTrue($this->schema->tableExists('test_timestamp'));
}
/**
* @covers \Drupal\sqlite\Driver\Database\sqlite\Schema::introspectIndexSchema
*/
public function testIntrospectIndexSchema(): void {
$table_specification = [
'fields' => [
'id' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
'test_field_1' => [
'type' => 'int',
'not null' => TRUE,
'default' => 0,
],
'test_field_2' => [
'type' => 'int',
'default' => 0,
],
'test_field_3' => [
'type' => 'int',
'default' => 0,
],
'test_field_4' => [
'type' => 'int',
'default' => 0,
],
'test_field_5' => [
'type' => 'int',
'default' => 0,
],
],
'primary key' => ['id', 'test_field_1'],
'unique keys' => [
'test_field_2' => ['test_field_2'],
'test_field_3_test_field_4' => ['test_field_3', 'test_field_4'],
],
'indexes' => [
'test_field_4' => ['test_field_4'],
'test_field_4_test_field_5' => ['test_field_4', 'test_field_5'],
],
];
$table_name = strtolower($this->getRandomGenerator()->name());
$this->schema->createTable($table_name, $table_specification);
unset($table_specification['fields']);
$introspect_index_schema = new \ReflectionMethod(get_class($this->schema), 'introspectIndexSchema');
$index_schema = $introspect_index_schema->invoke($this->schema, $table_name);
$this->assertEquals($table_specification, $index_schema);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\SchemaUniquePrefixedKeysIndexTestBase;
/**
* Tests adding UNIQUE keys to tables.
*
* @group Database
*/
class SchemaUniquePrefixedKeysIndexTest extends SchemaUniquePrefixedKeysIndexTestBase {
/**
* {@inheritdoc}
*/
protected string $columnValue = '1234567890 foo';
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\DriverSpecificSyntaxTestBase;
/**
* Tests SQLite syntax interpretation.
*
* @group Database
*/
class SyntaxTest extends DriverSpecificSyntaxTestBase {
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\TemporaryQueryTestBase;
/**
* Tests the temporary query functionality.
*
* @group Database
*/
class TemporaryQueryTest extends TemporaryQueryTestBase {
/**
* Confirms that temporary tables work.
*/
public function testTemporaryQuery(): void {
parent::testTemporaryQuery();
$connection = $this->getConnection();
$table_name_test = $connection->queryTemporary('SELECT [name] FROM {test}', []);
// Assert that the table is indeed a temporary one.
$this->assertStringContainsString("temp.", $table_name_test);
// Assert that both have the same field names.
$normal_table_fields = $connection->query("SELECT * FROM {test}")->fetch();
$temp_table_name = $connection->queryTemporary('SELECT * FROM {test}');
$temp_table_fields = $connection->query("SELECT * FROM $temp_table_name")->fetch();
$normal_table_fields = array_keys(get_object_vars($normal_table_fields));
$temp_table_fields = array_keys(get_object_vars($temp_table_fields));
$this->assertEmpty(array_diff($normal_table_fields, $temp_table_fields));
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Kernel\sqlite;
use Drupal\KernelTests\Core\Database\DriverSpecificTransactionTestBase;
/**
* Tests transaction for the SQLite driver.
*
* @group Database
*/
class TransactionTest extends DriverSpecificTransactionTestBase {
}

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\sqlite\Unit;
use Drupal\sqlite\Driver\Database\sqlite\Connection;
use Drupal\Tests\Core\Database\Stub\StubPDO;
use Drupal\Tests\UnitTestCase;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
/**
* @coversDefaultClass \Drupal\sqlite\Driver\Database\sqlite\Connection
* @group Database
*/
class ConnectionTest extends UnitTestCase {
/**
* @covers ::createConnectionOptionsFromUrl
* @dataProvider providerCreateConnectionOptionsFromUrl
*
* @param string $url
* SQLite URL.
* @param string $expected
* Expected connection option.
*/
public function testCreateConnectionOptionsFromUrl(string $url, string $expected): void {
$sqlite_connection = new Connection($this->createMock(StubPDO::class), []);
$database = $sqlite_connection->createConnectionOptionsFromUrl($url, NULL);
$this->assertEquals('sqlite', $database['driver']);
$this->assertEquals($expected, $database['database']);
}
/**
* Data provider for testCreateConnectionOptionsFromUrl.
*
* @return string[][]
* Associative array of arrays with the following elements:
* - SQLite database URL
* - Expected database connection option
*/
public static function providerCreateConnectionOptionsFromUrl(): array {
return [
'sqlite relative path' => ['sqlite://localhost/tmp/test', 'tmp/test'],
'sqlite absolute path' => ['sqlite://localhost//tmp/test', '/tmp/test'],
'in memory sqlite path' => ['sqlite://localhost/:memory:', ':memory:'],
];
}
/**
* Confirms deprecation of the $root argument.
*/
#[IgnoreDeprecations]
public function testDeprecationOfRootInConnectionOptionsFromUrl(): void {
$this->expectDeprecation('Passing the $root value to Drupal\sqlite\Driver\Database\sqlite\Connection::createConnectionOptionsFromUrl() is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3511287');
$root = dirname(__DIR__, 8);
$sqlite_connection = new Connection($this->createMock(StubPDO::class), []);
$database = $sqlite_connection->createConnectionOptionsFromUrl('sqlite://localhost/tmp/test', $root);
$this->assertEquals('sqlite', $database['driver']);
$this->assertEquals('tmp/test', $database['database']);
}
}