From bf58c108246021d99068933783e9efed847a5404 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Sat, 14 Mar 2026 11:18:41 +0000 Subject: [PATCH 1/3] MDL-88218 core: add experimental SQLite support --- public/admin/environment.xml | 1 + public/install.php | 5 +- public/lang/en/deprecated.txt | 1 - public/lang/en/install.php | 6 +- public/lib/ddl/sqlite_sql_generator.php | 402 +++++++++++++++ public/lib/dml/pdo_moodle_database.php | 6 + .../lib/dml/sqlite3_pdo_moodle_database.php | 468 ++++++++++++++++++ public/lib/installlib.php | 14 + 8 files changed, 899 insertions(+), 4 deletions(-) create mode 100644 public/lib/ddl/sqlite_sql_generator.php create mode 100644 public/lib/dml/sqlite3_pdo_moodle_database.php diff --git a/public/admin/environment.xml b/public/admin/environment.xml index 30a56c7abe5b1..b2d3eba05e43e 100644 --- a/public/admin/environment.xml +++ b/public/admin/environment.xml @@ -5120,6 +5120,7 @@ + diff --git a/public/install.php b/public/install.php index 9554e80fd9a6d..3e66c7b63a840 100644 --- a/public/install.php +++ b/public/install.php @@ -272,7 +272,7 @@ if ($config->stage == INSTALL_SAVE) { $CFG->early_install_lang = false; - $database = moodle_database::get_driver_instance($config->dbtype, 'native'); + $database = moodle_database::get_driver_instance($config->dbtype, install_get_dblibrary($config->dbtype)); if (!$database->driver_installed()) { $config->stage = INSTALL_DATABASETYPE; } else { @@ -408,7 +408,7 @@ if ($config->stage == INSTALL_DATABASE) { $CFG->early_install_lang = false; - $database = moodle_database::get_driver_instance($config->dbtype, 'native'); + $database = moodle_database::get_driver_instance($config->dbtype, install_get_dblibrary($config->dbtype)); $sub = '

'.$database->get_name().'

'.$database->get_configuration_help(); @@ -492,6 +492,7 @@ 'mariadb'=> moodle_database::get_driver_instance('mariadb', 'native'), 'pgsql' => moodle_database::get_driver_instance('pgsql', 'native'), 'sqlsrv' => moodle_database::get_driver_instance('sqlsrv', 'native'), // MS SQL*Server PHP driver + 'sqlite3' => moodle_database::get_driver_instance('sqlite3', 'pdo'), ); echo '
'; diff --git a/public/lang/en/deprecated.txt b/public/lang/en/deprecated.txt index f14974f4d5185..ff4bbd614804d 100644 --- a/public/lang/en/deprecated.txt +++ b/public/lang/en/deprecated.txt @@ -63,7 +63,6 @@ language_help,core_badges version_help,core_badges atto_h5p,core_h5p atto_h5p_description,core_h5p -sqliteextensionisnotpresentinphp,core_install siteevent,core_calendar categoryevent,core_calendar courseevent,core_calendar diff --git a/public/lang/en/install.php b/public/lang/en/install.php index 02bbc83d29ef2..be04a1068b897 100644 --- a/public/lang/en/install.php +++ b/public/lang/en/install.php @@ -231,5 +231,9 @@ $string['wwwroot'] = 'Web address'; $string['wwwrooterror'] = 'The \'Web Address\' does not appear to be valid - this Moodle installation doesn\'t appear to be there. The value below has been reset.'; -// Deprecated since Moodle 5.0. +$string['pdosqlite3'] = 'SQLite 3 (PDO)'; +$string['pdosqlite3help'] = '

The database is where most of the Moodle settings and data are stored and must be configured here.

+

The database file will be stored in the data directory. The database name and optional password are used to generate the filename.

+

No external database server is required.

'; + $string['sqliteextensionisnotpresentinphp'] = 'PHP has not been properly configured with the SQLite extension. Please check your php.ini file or recompile PHP.'; diff --git a/public/lib/ddl/sqlite_sql_generator.php b/public/lib/ddl/sqlite_sql_generator.php new file mode 100644 index 0000000000000..c45483e9ad7e0 --- /dev/null +++ b/public/lib/ddl/sqlite_sql_generator.php @@ -0,0 +1,402 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/ddl/sql_generator.php'); + +/** + * SQLite specific SQL code generator. + * + * @package core + * @subpackage ddl + * @copyright 2026 Ernesto Serrano + * @copyright based on work by 2008 Andrei Bautu + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sqlite_sql_generator extends sql_generator { + public $drop_default_value_required = true; + public $drop_default_value = null; + + public $drop_primary_key = 'ALTER TABLE TABLENAME DROP PRIMARY KEY'; + public $drop_unique_key = 'ALTER TABLE TABLENAME DROP KEY KEYNAME'; + public $drop_foreign_key = 'ALTER TABLE TABLENAME DROP FOREIGN KEY KEYNAME'; + public $default_for_char = ''; + + public $sequence_only = true; + public $sequence_extra_code = false; + public $sequence_name = 'INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL'; + public $unsigned_allowed = false; + + public $enum_inline_code = true; + public $enum_extra_code = false; + + public $drop_index_sql = 'ALTER TABLE TABLENAME DROP INDEX INDEXNAME'; + + public $rename_index_sql = null; + public $rename_key_sql = null; + + /** + * Creates one new SQLite SQL generator. + * @param moodle_database $mdb + * @param moodle_temptables|null $temptables + */ + public function __construct($mdb, $temptables = null) { + parent::__construct($mdb, $temptables); + } + + /** + * Reset a sequence to the id field of a table. + * @param string|xmldb_table $table + * @return array + */ + public function getResetSequenceSQL($table) { + if ($table instanceof xmldb_table) { + $table = $table->getName(); + } + + $value = (int)$this->mdb->get_field_sql('SELECT MAX(id) FROM {' . $table . '}'); + return array("UPDATE sqlite_sequence SET seq=$value WHERE name='{$this->prefix}{$table}'"); + } + + /** + * Given one correct xmldb_table, returns the SQL statements to create a temporary table. + * @param xmldb_table $xmldb_table + * @return array + */ + public function getCreateTempTableSQL($xmldb_table) { + $this->temptables->add_temptable($xmldb_table->getName()); + $sqlarr = $this->getCreateTableSQL($xmldb_table); + $sqlarr = preg_replace('/^CREATE TABLE/', 'CREATE TEMPORARY TABLE', $sqlarr); + return $sqlarr; + } + + /** + * Given one correct xmldb_key, returns its specs. + * @param xmldb_table $xmldb_table + * @param xmldb_key $xmldb_key + * @return string + */ + public function getKeySQL($xmldb_table, $xmldb_key) { + $key = ''; + + switch ($xmldb_key->getType()) { + case XMLDB_KEY_PRIMARY: + if ($this->primary_keys && count($xmldb_key->getFields()) > 1) { + if ($this->primary_key_name !== null) { + $key = $this->getEncQuoted($this->primary_key_name); + } else { + $key = $this->getNameForObject($xmldb_table->getName(), implode(', ', $xmldb_key->getFields()), 'pk'); + } + $key .= ' PRIMARY KEY (' . implode(', ', $this->getEncQuoted($xmldb_key->getFields())) . ')'; + } + break; + case XMLDB_KEY_UNIQUE: + if ($this->unique_keys) { + $key = $this->getNameForObject($xmldb_table->getName(), implode(', ', $xmldb_key->getFields()), 'uk'); + $key .= ' UNIQUE (' . implode(', ', $this->getEncQuoted($xmldb_key->getFields())) . ')'; + } + break; + case XMLDB_KEY_FOREIGN: + case XMLDB_KEY_FOREIGN_UNIQUE: + if ($this->foreign_keys) { + $key = $this->getNameForObject($xmldb_table->getName(), implode(', ', $xmldb_key->getFields()), 'fk'); + $key .= ' FOREIGN KEY (' . implode(', ', $this->getEncQuoted($xmldb_key->getFields())) . ')'; + $key .= ' REFERENCES ' . $this->getEncQuoted($this->prefix . $xmldb_key->getRefTable()); + $key .= ' (' . implode(', ', $this->getEncQuoted($xmldb_key->getRefFields())) . ')'; + } + break; + } + + return $key; + } + + /** + * Given one XMLDB Type, length and decimals, returns the DB proper SQL type. + * @param int $xmldb_type + * @param int|null $xmldb_length + * @param int|null $xmldb_decimals + * @return string + */ + public function getTypeSQL($xmldb_type, $xmldb_length = null, $xmldb_decimals = null) { + switch ($xmldb_type) { + case XMLDB_TYPE_INTEGER: + if (empty($xmldb_length)) { + $xmldb_length = 10; + } + $dbtype = 'INTEGER(' . $xmldb_length . ')'; + break; + case XMLDB_TYPE_NUMBER: + $dbtype = $this->number_type; + if (!empty($xmldb_length)) { + $dbtype .= '(' . $xmldb_length; + if (!empty($xmldb_decimals)) { + $dbtype .= ',' . $xmldb_decimals; + } + $dbtype .= ')'; + } + break; + case XMLDB_TYPE_FLOAT: + $dbtype = 'REAL'; + if (!empty($xmldb_length)) { + $dbtype .= '(' . $xmldb_length; + if (!empty($xmldb_decimals)) { + $dbtype .= ',' . $xmldb_decimals; + } + $dbtype .= ')'; + } + break; + case XMLDB_TYPE_CHAR: + $dbtype = 'VARCHAR'; + if (empty($xmldb_length)) { + $xmldb_length = '255'; + } + $dbtype .= '(' . $xmldb_length . ')'; + break; + case XMLDB_TYPE_BINARY: + $dbtype = 'BLOB'; + break; + case XMLDB_TYPE_DATETIME: + $dbtype = 'DATETIME'; + break; + case XMLDB_TYPE_TEXT: + default: + $dbtype = 'TEXT'; + break; + } + return $dbtype; + } + + /** + * Function to emulate full ALTER TABLE which SQLite does not support. + * @param xmldb_table $xmldb_table + * @param xmldb_field|null $xmldb_add_field + * @param xmldb_field|null $xmldb_delete_field + * @return array + */ + protected function getAlterTableSchema($xmldb_table, $xmldb_add_field = null, $xmldb_delete_field = null) { + $tablename = $this->getTableName($xmldb_table); + + $oldname = $xmldb_delete_field ? $xmldb_delete_field->getName() : null; + $newname = $xmldb_add_field ? $xmldb_add_field->getName() : null; + if ($xmldb_delete_field) { + $xmldb_table->deleteField($oldname); + } + if ($xmldb_add_field) { + $xmldb_table->addField($xmldb_add_field); + } + if ($oldname) { + $indexes = $xmldb_table->getIndexes(); + foreach ($indexes as $index) { + $fields = $index->getFields(); + $i = array_search($oldname, $fields); + if ($i !== false) { + if ($newname) { + $fields[$i] = $newname; + } else { + unset($fields[$i]); + } + $xmldb_table->deleteIndex($index->getName()); + if (count($fields)) { + $index->setFields($fields); + $xmldb_table->addIndex($index); + } + } + } + $keys = $xmldb_table->getKeys(); + foreach ($keys as $key) { + $fields = $key->getFields(); + $reffields = $key->getRefFields(); + $i = array_search($oldname, $fields); + if ($i !== false) { + if ($newname) { + $fields[$i] = $newname; + } else { + unset($fields[$i]); + unset($reffields[$i]); + } + $xmldb_table->deleteKey($key->getName()); + if (count($fields)) { + $key->setFields($fields); + $key->setRefFields($fields); + $xmldb_table->addkey($key); + } + } + } + } + + $fields = $xmldb_table->getFields(); + foreach ($fields as $key => $field) { + $fieldname = $field->getName(); + if ($fieldname == $newname && $oldname && $oldname != $newname) { + $fields[$key] = $this->getEncQuoted($oldname) . ' AS ' . $this->getEncQuoted($newname); + } else { + $fields[$key] = $this->getEncQuoted($field->getName()); + } + } + $fields = implode(',', $fields); + $results[] = 'BEGIN TRANSACTION'; + $results[] = 'CREATE TEMPORARY TABLE temp_data AS SELECT * FROM ' . $tablename; + $results[] = 'DROP TABLE ' . $tablename; + $results = array_merge($results, $this->getCreateTableSQL($xmldb_table)); + $results[] = 'INSERT INTO ' . $tablename . ' SELECT ' . $fields . ' FROM temp_data'; + $results[] = 'DROP TABLE temp_data'; + $results[] = 'COMMIT'; + return $results; + } + + public function getAlterFieldSQL($xmldb_table, $xmldb_field, $skip_type_clause = null, $skip_default_clause = null, $skip_notnull_clause = null) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $xmldb_field); + } + + public function getAddKeySQL($xmldb_table, $xmldb_key) { + $xmldb_table->addKey($xmldb_key); + return $this->getAlterTableSchema($xmldb_table); + } + + public function getCreateEnumSQL($xmldb_table, $xmldb_field) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $xmldb_field); + } + + public function getDropEnumSQL($xmldb_table, $xmldb_field) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $xmldb_field); + } + + public function getCreateDefaultSQL($xmldb_table, $xmldb_field) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $xmldb_field); + } + + public function getRenameFieldSQL($xmldb_table, $xmldb_field, $newname) { + $oldfield = clone($xmldb_field); + $xmldb_field->setName($newname); + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $oldfield); + } + + public function getRenameTableSQL($xmldb_table, $newname) { + $oldtablename = $this->getTableName($xmldb_table); + $xmldb_table->setName($newname); + $newtablename = $this->getTableName($xmldb_table); + + return array('ALTER TABLE ' . $oldtablename . ' RENAME TO ' . $newtablename); + } + + public function getDropTableSQL($xmldb_table) { + return array('DROP TABLE ' . $this->getTableName($xmldb_table)); + } + + public function getAddFieldSQL($xmldb_table, $xmldb_field, $skip_type_clause = null, $skip_default_clause = null, $skip_notnull_clause = null) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field); + } + + public function getAddIndexSQL($xmldb_table, $xmldb_index) { + $xmldb_table->addIndex($xmldb_index); + return $this->getAlterTableSchema($xmldb_table); + } + + public function getRenameIndexSQL($xmldb_table, $xmldb_index, $newname) { + $dbindexname = $this->mdb->get_manager()->find_index_name($xmldb_table, $xmldb_index); + $xmldb_index->setName($newname); + $results = array('DROP INDEX ' . $dbindexname); + $results = array_merge($results, $this->getCreateIndexSQL($xmldb_table, $xmldb_index)); + return $results; + } + + public function getRenameKeySQL($xmldb_table, $xmldb_key, $newname) { + $xmldb_table->deleteKey($xmldb_key->getName()); + $xmldb_key->setName($newname); + $xmldb_table->addkey($xmldb_key); + return $this->getAlterTableSchema($xmldb_table); + } + + public function getDropFieldSQL($xmldb_table, $xmldb_field) { + return $this->getAlterTableSchema($xmldb_table, null, $xmldb_field); + } + + public function getDropIndexSQL($xmldb_table, $xmldb_index) { + $xmldb_table->deleteIndex($xmldb_index->getName()); + return $this->getAlterTableSchema($xmldb_table); + } + + public function getDropKeySQL($xmldb_table, $xmldb_key) { + $xmldb_table->deleteKey($xmldb_key->getName()); + return $this->getAlterTableSchema($xmldb_table); + } + + public function getDropDefaultSQL($xmldb_table, $xmldb_field) { + return $this->getAlterTableSchema($xmldb_table, $xmldb_field, $xmldb_field); + } + + public function getCommentSQL($xmldb_table) { + return array(); + } + + /** + * Given one xmldb_table returns one array with all the check constraints. + * @param xmldb_table $xmldb_table + * @param xmldb_field|null $xmldb_field + * @return array + */ + public function getCheckConstraintsFromDB($xmldb_table, $xmldb_field = null) { + $tablename = $xmldb_table->getName(); + if (!$columns = $this->mdb->get_columns($tablename, false)) { + return array(); + } + $results = array(); + $filter = $xmldb_field ? $xmldb_field->getName() : null; + foreach ($columns as $key => $column) { + if (!empty($column->enums) && (!$filter || $column->name == $filter)) { + $result = new stdClass(); + $result->name = $key; + $result->description = implode(', ', $column->enums); + $results[$key] = $result; + } + } + return $results; + } + + public function isNameInUse($object_name, $type, $table_name) { + return false; + } + + public static function getReservedWords() { + return array( + 'add', 'all', 'alter', 'and', 'as', 'autoincrement', + 'between', 'by', + 'case', 'check', 'collate', 'column', 'commit', 'constraint', 'create', 'cross', + 'default', 'deferrable', 'delete', 'distinct', 'drop', + 'else', 'escape', 'except', 'exists', + 'foreign', 'from', 'full', + 'group', + 'having', + 'in', 'index', 'inner', 'insert', 'intersect', 'into', 'is', 'isnull', + 'join', + 'left', 'limit', + 'natural', 'not', 'notnull', 'null', + 'on', 'or', 'order', 'outer', + 'primary', + 'references', 'regexp', 'right', 'rollback', + 'select', 'set', + 'table', 'then', 'to', 'transaction', + 'union', 'unique', 'update', 'using', + 'values', + 'when', 'where', + ); + } + + public function addslashes($s) { + return str_replace("'", "''", $s); + } +} diff --git a/public/lib/dml/pdo_moodle_database.php b/public/lib/dml/pdo_moodle_database.php index 3a2a8471524ce..d8e7b22282a03 100644 --- a/public/lib/dml/pdo_moodle_database.php +++ b/public/lib/dml/pdo_moodle_database.php @@ -555,33 +555,39 @@ public function sql_group_concat(string $field, string $separator = ', ', string } protected function begin_transaction() { + $result = true; $this->query_start('', NULL, SQL_QUERY_AUX); try { $this->pdb->beginTransaction(); } catch(PDOException $ex) { $this->lastError = $ex->getMessage(); + $result = false; } $this->query_end($result); } protected function commit_transaction() { + $result = true; $this->query_start('', NULL, SQL_QUERY_AUX); try { $this->pdb->commit(); } catch(PDOException $ex) { $this->lastError = $ex->getMessage(); + $result = false; } $this->query_end($result); } protected function rollback_transaction() { + $result = true; $this->query_start('', NULL, SQL_QUERY_AUX); try { $this->pdb->rollBack(); } catch(PDOException $ex) { $this->lastError = $ex->getMessage(); + $result = false; } $this->query_end($result); } diff --git a/public/lib/dml/sqlite3_pdo_moodle_database.php b/public/lib/dml/sqlite3_pdo_moodle_database.php new file mode 100644 index 0000000000000..fec2e2f4ea59b --- /dev/null +++ b/public/lib/dml/sqlite3_pdo_moodle_database.php @@ -0,0 +1,468 @@ +. + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/pdo_moodle_database.php'); +require_once(__DIR__ . '/moodle_temptables.php'); + +/** + * SQLite 3 database driver using PDO. + * + * @package core_dml + * @copyright 2026 Ernesto Serrano + * @copyright based on work by 2008 Andrei Bautu + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sqlite3_pdo_moodle_database extends pdo_moodle_database { + /** @var string File extension for the SQLite database file. */ + protected $database_file_extension = '.sq3.php'; + + /** + * Connect to db and initialise temp table tracking required by current Moodle core. + * + * @param string $dbhost + * @param string $dbuser + * @param string $dbpass + * @param string $dbname + * @param mixed $prefix + * @param array|null $dboptions + * @return bool + */ + public function connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, ?array $dboptions = null) { + $result = parent::connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions); + $this->temptables = new moodle_temptables($this); + return $result; + } + + /** + * Detects if all needed PHP stuff installed. + * Note: can be used before connect() + * @return mixed true if ok, string if something + */ + public function driver_installed() { + if (!extension_loaded('pdo_sqlite') || !extension_loaded('pdo')) { + return get_string('sqliteextensionisnotpresentinphp', 'install'); + } + return true; + } + + /** + * Returns database family type. + * Note: can be used before connect() + * @return string + */ + public function get_dbfamily() { + return 'sqlite'; + } + + /** + * Returns more specific database driver type. + * Note: can be used before connect() + * @return string + */ + protected function get_dbtype() { + return 'sqlite3'; + } + + /** + * Avoid PDO connection options that are tuned for client/server drivers. + * @return array + */ + protected function get_pdooptions() { + return array(); + } + + /** + * Configure the database connection with SQLite-specific PRAGMAs. + */ + protected function configure_dbconnection() { + // Try to protect database file against web access when moodledata is web accessible. + $this->pdb->exec('CREATE TABLE IF NOT EXISTS "" (id int)'); + $this->pdb->exec('PRAGMA synchronous=OFF'); + $this->pdb->exec('PRAGMA short_column_names=1'); + $this->pdb->exec('PRAGMA encoding="UTF-8"'); + $this->pdb->exec('PRAGMA case_sensitive_like=0'); + $this->pdb->exec('PRAGMA locking_mode=NORMAL'); + } + + /** + * Attempt to create the database. + * @param string $dbhost + * @param string $dbuser + * @param string $dbpass + * @param string $dbname + * @param array|null $dboptions + * @return bool + */ + public function create_database($dbhost, $dbuser, $dbpass, $dbname, ?array $dboptions = null) { + global $CFG; + + $this->dbhost = $dbhost; + $this->dbuser = $dbuser; + $this->dbpass = $dbpass; + $this->dbname = $dbname; + $this->dboptions = $dboptions; + + $filepath = $this->get_dbfilepath(); + $dirpath = dirname($filepath); + @mkdir($dirpath, $CFG->directorypermissions, true); + return touch($filepath); + } + + /** + * Returns the driver-dependent DSN for PDO based on members stored by connect. + * @return string + */ + protected function get_dsn() { + return 'sqlite:' . $this->get_dbfilepath(); + } + + /** + * Returns the file path for the database file. + * @return string + */ + public function get_dbfilepath() { + global $CFG; + + if (!empty($this->dboptions['file'])) { + return $this->dboptions['file']; + } + if ($this->dbhost && $this->dbhost != 'localhost') { + $path = $this->dbhost; + } else { + $path = $CFG->dataroot; + } + $path = rtrim($path, '\\/') . '/'; + if (!empty($this->dbuser)) { + $path .= $this->dbuser . '_'; + } + $path .= $this->dbname . '_' . md5($this->dbpass) . $this->database_file_extension; + return $path; + } + + /** + * Returns the database server info. + * @return array An array with 'description' and 'version' keys. + */ + public function get_server_info(): array { + $version = ''; + try { + $version = $this->pdb->getAttribute(\PDO::ATTR_SERVER_VERSION); + } catch (\Exception $e) { + $version = '3.0.0'; + } + return array( + 'description' => 'SQLite ' . $version, + 'version' => $version, + ); + } + + /** + * Return tables in database without current prefix. + * @param bool $usecache + * @return array + */ + public function get_tables($usecache = true) { + $tables = array(); + + $sql = 'SELECT name FROM sqlite_master WHERE type="table" UNION ALL SELECT name FROM sqlite_temp_master WHERE type="table" ORDER BY name'; + if ($this->debug) { + $this->debug_query($sql); + } + $rstables = $this->pdb->query($sql); + foreach ($rstables as $table) { + $table = strtolower($table['name']); + if ($this->prefix !== false && $this->prefix !== '') { + if (strpos($table, $this->prefix) !== 0) { + continue; + } + $table = substr($table, strlen($this->prefix)); + } + $tables[$table] = $table; + } + return $tables; + } + + /** + * Return table indexes. + * @param string $table + * @return array + */ + public function get_indexes($table) { + $indexes = array(); + $sql = 'PRAGMA index_list(' . $this->prefix . $table . ')'; + if ($this->debug) { + $this->debug_query($sql); + } + $rsindexes = $this->pdb->query($sql); + foreach ($rsindexes as $index) { + $unique = (bool)$index['unique']; + $index = $index['name']; + $sql = 'PRAGMA index_info("' . $index . '")'; + if ($this->debug) { + $this->debug_query($sql); + } + $rscolumns = $this->pdb->query($sql); + $columns = array(); + foreach ($rscolumns as $row) { + $columns[] = strtolower($row['name']); + } + $index = strtolower($index); + $indexes[$index]['unique'] = $unique; + $indexes[$index]['columns'] = $columns; + } + return $indexes; + } + + /** + * Select rows and return first-column values as an array. + * Current Moodle core expects an empty array, not false, when no rows exist. + * + * @param string $sql + * @param array|null $params + * @return array|false + */ + public function get_fieldset_sql($sql, ?array $params = null) { + $rs = $this->get_recordset_sql($sql, $params); + if ($rs === false) { + return false; + } + if (!$rs->valid()) { + $rs->close(); + return array(); + } + $result = array(); + foreach ($rs as $value) { + $result[] = reset($value); + } + $rs->close(); + return $result; + } + + /** + * Return records indexed by the first selected column. + * Current Moodle core expects an empty array, not false, when no rows exist. + * + * @param string $sql + * @param array|null $params + * @param int $limitfrom + * @param int $limitnum + * @return array|false + */ + public function get_records_sql($sql, ?array $params = null, $limitfrom = 0, $limitnum = 0) { + global $CFG; + + $rs = $this->get_recordset_sql($sql, $params, $limitfrom, $limitnum); + if ($rs === false) { + return false; + } + if (!$rs->valid()) { + $rs->close(); + return array(); + } + $objects = array(); + foreach ($rs as $value) { + $row = (array)$value; + $key = reset($row); + if ($CFG->debugdeveloper && array_key_exists($key, $objects)) { + debugging("Did you remember to make the first column something unique in your call to get_records? Duplicate value '$key' found in column first column of '$sql'.", DEBUG_DEVELOPER); + } + $objects[$key] = (object)$row; + } + $rs->close(); + return $objects; + } + + /** + * Returns detailed information about columns in table. + * @param string $table + * @return array + */ + protected function fetch_columns(string $table): array { + $structure = array(); + + $sql = 'SELECT sql FROM sqlite_master WHERE type="table" AND tbl_name="' . $this->prefix . $table . '"'; + if ($this->debug) { + $this->debug_query($sql); + } + $createsql = $this->pdb->query($sql)->fetch(); + if (!$createsql) { + return array(); + } + $createsql = $createsql['sql']; + + $sql = 'PRAGMA table_info("' . $this->prefix . $table . '")'; + if ($this->debug) { + $this->debug_query($sql); + } + $rscolumns = $this->pdb->query($sql); + foreach ($rscolumns as $row) { + $columninfo = array( + 'name' => strtolower($row['name']), + 'not_null' => (bool)$row['notnull'], + 'primary_key' => (bool)$row['pk'], + 'has_default' => !is_null($row['dflt_value']), + 'default_value' => $row['dflt_value'], + 'auto_increment' => false, + 'binary' => false, + ); + $type = explode('(', $row['type']); + $columninfo['type'] = strtolower($type[0]); + if (count($type) > 1) { + $size = explode(',', trim($type[1], ')')); + $columninfo['max_length'] = $size[0]; + if (count($size) > 1) { + $columninfo['scale'] = $size[1]; + } + } + switch (substr($columninfo['type'], 0, 3)) { + case 'int': + if ($columninfo['primary_key'] && preg_match('/' . $columninfo['name'] . '\W+integer\W+primary\W+key\W+autoincrement/im', $createsql)) { + $columninfo['meta_type'] = 'R'; + $columninfo['auto_increment'] = true; + } else { + $columninfo['meta_type'] = 'I'; + } + break; + case 'num': + case 'rea': + case 'dou': + case 'flo': + $columninfo['meta_type'] = 'N'; + break; + case 'var': + case 'cha': + case 'enu': + $columninfo['meta_type'] = 'C'; + break; + case 'tex': + case 'clo': + $columninfo['meta_type'] = 'X'; + break; + case 'blo': + case 'non': + $columninfo['meta_type'] = 'B'; + $columninfo['binary'] = true; + break; + case 'boo': + case 'bit': + case 'log': + $columninfo['meta_type'] = 'L'; + $columninfo['max_length'] = 1; + break; + case 'tim': + $columninfo['meta_type'] = 'T'; + break; + case 'dat': + $columninfo['meta_type'] = 'D'; + break; + } + if ($columninfo['has_default'] && ($columninfo['meta_type'] == 'X' || $columninfo['meta_type'] == 'C')) { + $columninfo['default_value'] = substr($columninfo['default_value'], 1, -1); + } + $structure[$columninfo['name']] = new database_column_info($columninfo); + } + + return $structure; + } + + /** + * Normalise values based in RDBMS dependencies. + * @param database_column_info $column + * @param mixed $value + * @return mixed + */ + protected function normalise_value($column, $value) { + return $value; + } + + /** + * Returns the sql statement with clauses to append used to limit a recordset range. + * @param string $sql + * @param int $limitfrom + * @param int $limitnum + * @return string + */ + protected function get_limit_clauses($sql, $limitfrom = 0, $limitnum = 0) { + if ($limitnum) { + $sql .= ' LIMIT ' . $limitnum; + if ($limitfrom) { + $sql .= ' OFFSET ' . $limitfrom; + } + } + return $sql; + } + + /** + * Delete the records from a table where all the given conditions met. + * @param string $table + * @param array|null $conditions + * @return bool + */ + public function delete_records($table, ?array $conditions = null) { + if (is_null($conditions)) { + return $this->execute("DELETE FROM {{$table}}"); + } + list($select, $params) = $this->where_clause($table, $conditions); + return $this->delete_records_select($table, $select, $params); + } + + /** + * Returns the proper SQL to do CONCAT between the elements passed. + * @param string ...$elements + * @return string + */ + public function sql_concat(...$elements) { + return implode('||', $elements); + } + + /** + * Returns the proper SQL to do CONCAT between the elements passed with a separator. + * @param string $separator + * @param array $elements + * @return string + */ + public function sql_concat_join($separator = "' '", $elements = array()) { + for ($n = count($elements) - 1; $n > 0; $n--) { + array_splice($elements, $n, 0, $separator); + } + return implode('||', $elements); + } + + /** + * Returns the SQL text to be used in order to perform one bitwise XOR operation between 2 integers. + * @param int $int1 + * @param int $int2 + * @return string + */ + public function sql_bitxor($int1, $int2) { + return '( ~' . $this->sql_bitand($int1, $int2) . ' & ' . $this->sql_bitor($int1, $int2) . ')'; + } + + /** + * Return SQL for performing group concatenation on given field/expression. + * + * @param string $field + * @param string $separator + * @param string $sort + * @return string + */ + public function sql_group_concat(string $field, string $separator = ', ', string $sort = ''): string { + $separator = $this->pdb->quote($separator); + return "GROUP_CONCAT({$field}, {$separator})"; + } +} diff --git a/public/lib/installlib.php b/public/lib/installlib.php index 69e1ad268093b..974543069a010 100644 --- a/public/lib/installlib.php +++ b/public/lib/installlib.php @@ -41,6 +41,20 @@ /** INSTALL_SAVE = 6 */ define('INSTALL_SAVE', 6); +/** + * Return the database library for the given database type. + * + * @param string $dbtype + * @return string + */ +function install_get_dblibrary(string $dbtype): string { + $libraries = array( + 'sqlite3' => 'pdo', + ); + + return $libraries[$dbtype] ?? 'native'; +} + /** * Tries to detect the right www root setting. * @return string detected www root From 6af92084828553a436038f31e93637a607e6b569 Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 4 Apr 2026 10:42:23 +0100 Subject: [PATCH 2/3] MDL-88218 dml: fix fetch_columns() to find temporary tables in SQLite The fetch_columns() method only queried sqlite_master for table metadata, but SQLite stores temporary tables in sqlite_temp_master. This caused "Table does not exist" errors when Moodle tried to access temporary tables (e.g. backup_ids_temp during activity duplication). Add UNION ALL with sqlite_temp_master, consistent with the existing pattern in get_tables(). --- public/lib/dml/sqlite3_pdo_moodle_database.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/lib/dml/sqlite3_pdo_moodle_database.php b/public/lib/dml/sqlite3_pdo_moodle_database.php index fec2e2f4ea59b..24103eb50b448 100644 --- a/public/lib/dml/sqlite3_pdo_moodle_database.php +++ b/public/lib/dml/sqlite3_pdo_moodle_database.php @@ -295,7 +295,9 @@ public function get_records_sql($sql, ?array $params = null, $limitfrom = 0, $li protected function fetch_columns(string $table): array { $structure = array(); - $sql = 'SELECT sql FROM sqlite_master WHERE type="table" AND tbl_name="' . $this->prefix . $table . '"'; + $sql = 'SELECT sql FROM sqlite_master WHERE type="table" AND tbl_name="' . $this->prefix . $table . '"' + . ' UNION ALL ' + . 'SELECT sql FROM sqlite_temp_master WHERE type="table" AND tbl_name="' . $this->prefix . $table . '"'; if ($this->debug) { $this->debug_query($sql); } From 4895409b06e8916fe7a6cbc692aa4871691bd0ca Mon Sep 17 00:00:00 2001 From: erseco Date: Sat, 11 Jul 2026 09:33:13 +0100 Subject: [PATCH 3/3] MDL-88218: Pass an object to database_column_info in the SQLite driver database_column_info::__construct() reads its fields via object property access, so the SQLite driver's fetch_columns() must pass an object like the pgsql and mysqli drivers do. Passing the raw array left every property (including ->name) null, which broke callers reading $column->name (e.g. theme_snap's events query produced 'SELECT e., e., ...' and a syntax error). --- public/lib/dml/sqlite3_pdo_moodle_database.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/lib/dml/sqlite3_pdo_moodle_database.php b/public/lib/dml/sqlite3_pdo_moodle_database.php index 24103eb50b448..ecfb191399c7a 100644 --- a/public/lib/dml/sqlite3_pdo_moodle_database.php +++ b/public/lib/dml/sqlite3_pdo_moodle_database.php @@ -376,7 +376,10 @@ protected function fetch_columns(string $table): array { if ($columninfo['has_default'] && ($columninfo['meta_type'] == 'X' || $columninfo['meta_type'] == 'C')) { $columninfo['default_value'] = substr($columninfo['default_value'], 1, -1); } - $structure[$columninfo['name']] = new database_column_info($columninfo); + // database_column_info reads its fields via object property access, so it must + // be given an object like the pgsql and mysqli drivers do; passing the raw array + // leaves every property (including ->name) null. + $structure[$columninfo['name']] = new database_column_info((object) $columninfo); } return $structure;