* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\Database\SQLite3; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\InvalidArgumentException; use Exception; use SQLite3; use SQLite3Result; use stdClass; /** * Connection for SQLite3 * * @extends BaseConnection */ class Connection extends BaseConnection { /** * Database driver * * @var string */ public $DBDriver = 'SQLite3'; /** * Identifier escape character * * @var string */ public $escapeChar = '"'; /** * @var bool Enable Foreign Key constraint or not */ protected $foreignKeys = false; /** * The milliseconds to sleep when database is locked. * 迅睿默认 10s,避免并发写时立刻抛 database is locked。 * * @var int|null milliseconds * * @see https://www.php.net/manual/en/sqlite3.busytimeout */ protected $busyTimeout = 10000; /** * The setting of the "synchronous" flag * * @var int<0, 3>|null flag * * @see https://www.sqlite.org/pragma.html#pragma_synchronous */ protected ?int $synchronous = null; /** * @return void */ public function initialize() { parent::initialize(); if ($this->foreignKeys) { $this->enableForeignKeyChecks(); } $timeout = is_int($this->busyTimeout) ? $this->busyTimeout : 10000; if ($timeout > 0 && $this->connID instanceof SQLite3) { $this->connID->busyTimeout($timeout); // 双保险:部分环境 busyTimeout() 对 PRAGMA 路径不生效 @$this->connID->exec('PRAGMA busy_timeout = ' . $timeout); } // WAL 提升并发读;内存库跳过 if ($this->connID instanceof SQLite3 && $this->database !== ':memory:') { @$this->connID->exec('PRAGMA journal_mode = WAL'); @$this->connID->exec('PRAGMA synchronous = NORMAL'); } if (is_int($this->synchronous)) { if (! in_array($this->synchronous, [0, 1, 2, 3], true)) { throw new InvalidArgumentException('Invalid synchronous value.'); } $this->connID->exec('PRAGMA synchronous = ' . $this->synchronous); } } /** * Connect to the database. * * @return SQLite3 * * @throws DatabaseException */ public function connect(bool $persistent = false) { if ($persistent && $this->DBDebug) { throw new DatabaseException('SQLite3 doesn\'t support persistent connections.'); } try { if ($this->database !== ':memory:' && ! str_contains($this->database, DIRECTORY_SEPARATOR)) { $this->database = WRITEPATH . $this->database; } $sqlite = (! isset($this->password) || $this->password !== '') ? new SQLite3($this->database) : new SQLite3($this->database, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE, $this->password); $sqlite->enableExceptions(true); return $sqlite; } catch (Exception $e) { throw new DatabaseException('SQLite3 error: ' . $e->getMessage()); } } /** * Keep or establish the connection if no queries have been sent for * a length of time exceeding the server's idle timeout. * * @return void */ public function reconnect() { $this->close(); $this->initialize(); } /** * Close the database connection. * * @return void */ protected function _close() { $this->connID->close(); } /** * Select a specific database table to use. */ public function setDatabase(string $databaseName): bool { return false; } /** * Returns a string containing the version of the database being used. */ public function getVersion(): string { if (isset($this->dataCache['version'])) { return $this->dataCache['version']; } $version = SQLite3::version(); return $this->dataCache['version'] = $version['versionString']; } /** * Execute the query * * @return false|SQLite3Result */ protected function execute(string $sql) { try { return $this->isWriteType($sql) ? $this->connID->exec($sql) : $this->connID->query($sql); } catch (Exception $e) { log_message('error', (string) $e); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } } return false; } /** * Returns the total number of rows affected by this query. */ public function affectedRows(): int { return $this->connID->changes(); } /** * Platform-dependant string escape */ protected function _escapeString(string $str): string { if (! $this->connID instanceof SQLite3) { $this->initialize(); } return $this->connID->escapeString($str); } /** * Generates the SQL for listing tables in a platform-dependent manner. * * @param string|null $tableName If $tableName is provided will return only this table if exists. */ protected function _listTables(bool $prefixLimit = false, ?string $tableName = null): string { if ((string) $tableName !== '') { return 'SELECT "NAME" FROM "SQLITE'.'_'.'MASTER" WHERE "TYPE" = \'table\'' . ' AND "NAME" NOT LIKE \'sqlite!_%\' ESCAPE \'!\'' . ' AND "NAME" LIKE ' . $this->escape($tableName); } return 'SELECT "NAME" FROM "SQLITE'.'_'.'MASTER" WHERE "TYPE" = \'table\'' . ' AND "NAME" NOT LIKE \'sqlite!_%\' ESCAPE \'!\'' . (($prefixLimit && $this->DBPrefix !== '') ? ' AND "NAME" LIKE \'' . $this->escapeLikeString($this->DBPrefix) . '%\' ' . sprintf($this->likeEscapeStr, $this->likeEscapeChar) : ''); } /** * Generates a platform-specific query string so that the column names can be fetched. * * @param string|TableName $table */ protected function _listColumns($table = ''): string { if ($table instanceof TableName) { $tableName = $this->escapeIdentifier($table); } else { $tableName = $this->protectIdentifiers($table, true, null, false); } return 'PRAGMA TABLE_INFO(' . $tableName . ')'; } /** * @param string|TableName $tableName * * @return false|list * * @throws DatabaseException */ public function getFieldNames($tableName) { $table = ($tableName instanceof TableName) ? $tableName->getTableName() : $tableName; // Is there a cached result? if (isset($this->dataCache['field_names'][$table])) { return $this->dataCache['field_names'][$table]; } if (! $this->connID instanceof SQLite3) { $this->initialize(); } $sql = $this->_listColumns($tableName); $query = $this->query($sql); $this->dataCache['field_names'][$table] = []; foreach ($query->getResultArray() as $row) { // Do we know from where to get the column's name? if (! isset($key)) { if (isset($row['column_name'])) { $key = 'column_name'; } elseif (isset($row['COLUMN_NAME'])) { $key = 'COLUMN_NAME'; } elseif (isset($row['name'])) { $key = 'name'; } else { // We have no other choice but to just get the first element's key. $key = key($row); } } $this->dataCache['field_names'][$table][] = $row[$key]; } return $this->dataCache['field_names'][$table]; } /** * Returns an array of objects with field data * * @return list * * @throws DatabaseException */ protected function _fieldData(string $table): array { if (false === $query = $this->query('PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')')) { throw new DatabaseException(lang('Database.failGetFieldData')); } $query = $query->getResultObject(); if (empty($query)) { return []; } $retVal = []; for ($i = 0, $c = count($query); $i < $c; $i++) { $retVal[$i] = new stdClass(); $retVal[$i]->name = $query[$i]->name; $retVal[$i]->type = $query[$i]->type; $retVal[$i]->max_length = null; $retVal[$i]->nullable = isset($query[$i]->notnull) && ! (bool) $query[$i]->notnull; $retVal[$i]->default = $query[$i]->dflt_value; // "pk" (either zero for columns that are not part of the primary key, // or the 1-based index of the column within the primary key). // https://www.sqlite.org/pragma.html#pragma_table_info $retVal[$i]->primary_key = ($query[$i]->pk === 0) ? 0 : 1; } return $retVal; } /** * Returns an array of objects with index data * * @return array * * @throws DatabaseException */ protected function _indexData(string $table): array { $sql = "SELECT 'PRIMARY' as indexname, l.name as fieldname, 'PRIMARY' as indextype FROM pragma_table_info(" . $this->escape(strtolower($table)) . ") as l WHERE l.pk <> 0 UNION ALL SELECT sqlite_master.name as indexname, ii.name as fieldname, CASE WHEN ti.pk <> 0 AND sqlite_master.name LIKE 'sqlite_autoindex_%' THEN 'PRIMARY' WHEN sqlite_master.name LIKE 'sqlite_autoindex_%' THEN 'UNIQUE' WHEN sqlite_master.sql LIKE '% UNIQUE %' THEN 'UNIQUE' ELSE 'INDEX' END as indextype FROM sqlite_master INNER JOIN pragma_index_xinfo(sqlite_master.name) ii ON ii.name IS NOT NULL LEFT JOIN pragma_table_info(" . $this->escape(strtolower($table)) . ") ti ON ti.name = ii.name WHERE sqlite_master.type='index' AND sqlite_master.tbl_name = " . $this->escape(strtolower($table)) . ' COLLATE NOCASE'; if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetIndexData')); } $query = $query->getResultObject(); $tempVal = []; foreach ($query as $row) { if ($row->indextype === 'PRIMARY') { $tempVal['PRIMARY']['indextype'] = $row->indextype; $tempVal['PRIMARY']['indexname'] = $row->indexname; $tempVal['PRIMARY']['fields'][$row->fieldname] = $row->fieldname; } else { $tempVal[$row->indexname]['indextype'] = $row->indextype; $tempVal[$row->indexname]['indexname'] = $row->indexname; $tempVal[$row->indexname]['fields'][$row->fieldname] = $row->fieldname; } } $retVal = []; foreach ($tempVal as $val) { $obj = new stdClass(); $obj->name = $val['indexname']; $obj->fields = array_values($val['fields']); $obj->type = $val['indextype']; $retVal[$obj->name] = $obj; } return $retVal; } /** * Returns an array of objects with Foreign key data * * @return array */ protected function _foreignKeyData(string $table): array { if (! $this->supportsForeignKeys()) { return []; } $query = $this->query("PRAGMA foreign_key_list({$table})")->getResult(); $indexes = []; foreach ($query as $row) { $indexes[$row->id]['constraint_name'] = null; $indexes[$row->id]['table_name'] = $table; $indexes[$row->id]['foreign_table_name'] = $row->table; $indexes[$row->id]['column_name'][] = $row->from; $indexes[$row->id]['foreign_column_name'][] = $row->to; $indexes[$row->id]['on_delete'] = $row->on_delete; $indexes[$row->id]['on_update'] = $row->on_update; $indexes[$row->id]['match'] = $row->match; } return $this->foreignKeyDataToObjects($indexes); } /** * Returns platform-specific SQL to disable foreign key checks. * * @return string */ protected function _disableForeignKeyChecks() { return 'PRAGMA foreign_keys = OFF'; } /** * Returns platform-specific SQL to enable foreign key checks. * * @return string */ protected function _enableForeignKeyChecks() { return 'PRAGMA foreign_keys = ON'; } /** * Returns the last error code and message. * Must return this format: ['code' => string|int, 'message' => string] * intval(code) === 0 means "no error". * * @return array */ public function error(): array { return [ 'code' => $this->connID->lastErrorCode(), 'message' => $this->connID->lastErrorMsg(), ]; } /** * Insert ID */ public function insertID(): int { return $this->connID->lastInsertRowID(); } /** * Begin Transaction */ protected function _transBegin(): bool { return $this->connID->exec('BEGIN TRANSACTION'); } /** * Commit Transaction */ protected function _transCommit(): bool { return $this->connID->exec('END TRANSACTION'); } /** * Rollback Transaction */ protected function _transRollback(): bool { return $this->connID->exec('ROLLBACK'); } /** * Checks to see if the current install supports Foreign Keys * and has them enabled. */ public function supportsForeignKeys(): bool { $result = $this->simpleQuery('PRAGMA foreign_keys'); return (bool) $result; } // -------------------------------------------------------------------- // 迅睿 DDL API(与 MySQLi\Connection 同名,供 Fcms/Model/Table 等调用) // -------------------------------------------------------------------- /** * 拦截 MySQL 风格 ALTER ... CHANGE,改为重建表修改列 * * {@inheritDoc} */ public function query(string $sql, $binds = null, bool $setEscapeFlags = true, string $queryClass = '') { if ($binds === null && is_string($sql)) { $trimmed = ltrim($sql); $id = '(?:[`"]?)([^\s`"]+)(?:[`"]?)'; if (preg_match( '/^ALTER\s+TABLE\s+'.$id.'\s+CHANGE\s+'.$id.'\s+'.$id.'\s+(.+)$/is', $trimmed, $m )) { $def = trim($m[4]); $def = preg_replace("/\s+COMMENT\s+'((?:''|[^'])*)'/i", '', $def) ?? $def; $def = preg_replace('/\s+COMMENT\s+"((?:""|[^"])*)"/i', '', $def) ?? $def; if (preg_match('/^(\S+(?:\([^)]*\))?)\s*(.*)$/s', trim($def), $d)) { $rt = $this->editField($m[1], $m[2], $d[1], trim($d[2]), ''); return $rt !== false; } } if (preg_match( '/^ALTER\s+TABLE\s+'.$id.'\s+DROP(?:\s+COLUMN)?\s+'.$id.'\s*$/is', $trimmed, $m )) { $rt = $this->dropField($m[1], $m[2]); return $rt !== false; } } return parent::query($sql, $binds, $setEscapeFlags, $queryClass); } /** * 规范化建表/DDL SQL(Install.sql 等以 MySQL 方言为源 → SQLite) */ public function formatCreateSql($sql) { if ($sql === '' || $sql === null) { return ''; } $sql = trim($sql); // DML 兼容 $sql = preg_replace('/\bREPLACE\s+INTO\b/i', 'INSERT OR REPLACE INTO', $sql) ?? $sql; $sql = preg_replace('/\bINSERT\s+IGNORE\s+INTO\b/i', 'INSERT OR IGNORE INTO', $sql) ?? $sql; // DROP TABLE 可直接用 if (preg_match('/^DROP\s+TABLE\b/i', $sql)) { return $sql; } // ALTER TABLE ADD / DROP(去掉 COMMENT) if (preg_match('/^ALTER\s+TABLE\b/i', $sql)) { $sql = preg_replace("/\s+COMMENT\s+'((?:''|[^'])*)'/i", '', $sql) ?? $sql; $sql = preg_replace('/\s+COMMENT\s+"((?:""|[^"])*)"/i', '', $sql) ?? $sql; // CHANGE 留给 query() 拦截重建 if (preg_match('/^ALTER\s+TABLE\s+[`]?([^`\s]+)[`]?\s+CHANGE\b/i', $sql)) { return $sql; } // DROP COLUMN 留给 query() 走重建(兼容旧 SQLite) if (preg_match('/^ALTER\s+TABLE\s+[`]?([^`\s]+)[`]?\s+DROP(?:\s+COLUMN)?\s+/i', $sql)) { return $sql; } // ADD 列类型映射 if (preg_match('/^(ALTER\s+TABLE\s+[`]?[^`\s]+[`]?\s+ADD(?:\s+COLUMN)?\s+[`]?[^`\s]+[`]?\s+)(.+)$/is', $sql, $m)) { return $m[1] . $this->_xrNormalizeMysqlColumnLine($m[2]); } return $sql; } // CREATE TABLE if (preg_match('/^CREATE\s+TABLE\b/i', $sql)) { return $this->_xrFormatCreateTableSql($sql); } return $sql; } /** * 生成「添加字段」SQL(表名占位符 {tablename}) */ public function sqlAddField($name, $type, $info, $note = '') { $def = $this->_xrNormalizeMysqlColumnLine(trim($type . ' ' . $info)); // 表名用占位符,避免反引号在 SQLite 下偶发语法问题;执行时再 escapeIdentifiers return 'ALTER TABLE {tablename} ADD COLUMN ' . $this->escapeIdentifiers($name) . ' ' . $def; } /** * 生成「修改字段」SQL(MySQL CHANGE 形态;执行时由 query() 拦截并重建表) */ public function sqlEditField($name, $type, $info, $note = '') { $id = $this->escapeIdentifiers($name); $def = $this->_xrNormalizeMysqlColumnLine(trim($type . ' ' . $info)); return 'ALTER TABLE {tablename} CHANGE ' . $id . ' ' . $id . ' ' . $def; } /** * 生成「删除字段」SQL */ public function sqlDropField($name) { return 'ALTER TABLE {tablename} DROP COLUMN ' . $this->escapeIdentifiers($name); } /** * 批量添加字段 */ public function sqlAddFields(array $columns) { // SQLite 一条 ALTER 只能 ADD 一列,拆成多条由调用方逐条执行不现实; // 返回第一条,其余由 sqlAddField 循环更稳妥。这里用分号拼接,simpleQuery/exec 可多语句。 $parts = []; foreach ($columns as $col) { if (empty($col['name'])) { continue; } $parts[] = $this->sqlAddField( $col['name'], $col['type'] ?? '', $col['info'] ?? '', $col['note'] ?? '' ); } return $parts === [] ? '' : implode(";\n", $parts); } /** * 批量删除字段 */ public function sqlDropFields(array $names) { $parts = []; foreach ($names as $name) { if ($name === '' || $name === null) { continue; } $parts[] = $this->sqlDropField($name); } return $parts === [] ? '' : implode(";\n", $parts); } /** * 删除表 */ public function dropTable($table, $ifExists = true) { $table = trim((string) $table, '`'); $sql = ($ifExists ? 'DROP TABLE IF EXISTS ' : 'DROP TABLE ') . $this->escapeIdentifiers($table); return $this->query($sql); } /** * 修改字段(重建表) */ public function editField($table, $name, $type, $info, $note) { $table = trim((string) $table, '`"[]'); $name = trim((string) $name, '`"[]'); $type = $this->_xrMapMysqlTypeOnly((string) $type); $info = (string) $info; $null = stripos($info, 'NOT NULL') === false; $field = [ 'name' => $name, 'type' => $type, 'null' => $null, ]; if (preg_match('/DEFAULT\s+(NULL|\'(?:\'\'|[^\'])*\'|"(?:""|[^"])*"|\S+)/i', $info, $m)) { $default = $m[1]; if (strtoupper($default) === 'NULL') { $field['default'] = null; } else { $field['default'] = trim($default, "'\""); } } try { $forge = new Forge($this); return (new Table($this, $forge)) ->fromTable($table) ->modifyColumn([$field]) ->run(); } catch (\Throwable $e) { log_message('error', 'SQLite3 editField: ' . $e->getMessage()); return false; } } /** * 添加字段 */ public function addField($table, $name, $type, $info, $note) { $table = trim((string) $table, '`"[]'); return $this->query(str_replace( '{tablename}', $this->escapeIdentifiers($table), $this->sqlAddField($name, $type, $info, $note) )); } /** * 删除字段(重建表,兼容旧版 SQLite) */ public function dropField($table, $name) { $table = trim((string) $table, '`"[]'); $name = trim((string) $name, '`"[]'); try { $forge = new Forge($this); return (new Table($this, $forge)) ->fromTable($table) ->dropColumn($name) ->run(); } catch (\Throwable $e) { log_message('error', 'SQLite3 dropField: ' . $e->getMessage()); return false; } } /** * 创建表 * * @param array $fields ['字段名' => '类型及属性SQL'] 或 [0 => '完整列定义SQL'] * @param array $indexs ['PRIMARY KEY (`id`)', 'KEY `name` (`name`)', ...] * @param string $note 表注释(SQLite 忽略,仅日志无关) */ public function createTable($table, $fields, $indexs, $note) { $table = trim((string) $table, '`"[]'); $lines = []; $extraIndexes = []; $pkInline = false; if (is_array($fields)) { foreach ($fields as $name => $def) { if (is_int($name)) { $line = $this->_xrNormalizeMysqlColumnLine((string) $def); if (preg_match('/PRIMARY\s+KEY/i', $line) && preg_match('/AUTOINCREMENT/i', $line)) { $pkInline = true; } $lines[] = $line; } else { $mapped = $this->_xrNormalizeMysqlColumnLine((string) $def); if (preg_match('/PRIMARY\s+KEY/i', $mapped) && preg_match('/AUTOINCREMENT/i', $mapped)) { $pkInline = true; } $lines[] = $this->escapeIdentifiers($name) . ' ' . $mapped; } } } if (is_array($indexs)) { foreach ($indexs as $idx) { $idx = trim((string) $idx); if ($idx === '') { continue; } if (preg_match('/^PRIMARY\s+KEY/i', $idx)) { if (! $pkInline) { // 去掉 MySQL 反引号 $idx = preg_replace('/[`"]/', '', $idx) ?? $idx; $lines[] = $idx; } continue; } // UNIQUE KEY `uid` (`uid`) → UNIQUE (`uid`) if (preg_match('/^UNIQUE(?:\s+KEY|\s+INDEX)?\s+(?:[`"]?\w+[`]?\s+)?\((.+)\)/i', $idx, $m)) { $cols = preg_replace('/[`"]/', '', $this->_xrStripMysqlIndexPrefix($m[1])) ?? $m[1]; $lines[] = 'UNIQUE (' . $cols . ')'; continue; } // KEY `name` (`col`) / INDEX → 表外 CREATE INDEX(索引名加表前缀,SQLite 全局唯一) if (preg_match('/^(?:KEY|INDEX)\s+(?:[`"]?(\w+)[`]?\s+)?\((.+)\)/i', $idx, $m)) { $extraIndexes[] = [ 'name' => $m[1] ?? '', 'cols' => preg_replace('/[`"]/', '', $this->_xrStripMysqlIndexPrefix($m[2])) ?? $m[2], ]; continue; } $lines[] = preg_replace('/[`"]/', '', $idx) ?? $idx; } } if ($lines === []) { log_message('error', 'SQLite3 createTable: empty fields for ' . $table); return false; } $sql = 'CREATE TABLE IF NOT EXISTS ' . $this->escapeIdentifiers($table) . " ( " . implode(",\n", $lines) . ' )'; try { $rt = $this->query($sql); } catch (\Throwable $e) { log_message('error', 'SQLite3 createTable: ' . $e->getMessage() . ' SQL: ' . $sql); return false; } if ($rt === false) { $err = $this->error(); log_message('error', 'SQLite3 createTable failed: ' . ($err['message'] ?? '') . ' SQL: ' . $sql); return false; } // 建表后刷新表名缓存,避免 tableExists 误判 $this->resetDataCache(); $safeTable = preg_replace('/[^a-zA-Z0-9_]/', '_', $table) ?: 't'; foreach ($extraIndexes as $i => $ix) { $base = $ix['name'] !== '' ? $ix['name'] : ('idx_' . $i); $idxName = $safeTable . '_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $base); try { $this->query( 'CREATE INDEX IF NOT EXISTS ' . $this->escapeIdentifiers($idxName) . ' ON ' . $this->escapeIdentifiers($table) . ' (' . $ix['cols'] . ')' ); } catch (\Throwable $e) { log_message('error', 'SQLite3 createIndex: ' . $e->getMessage()); } } return $rt; } /** * 获取建表 SQL(CREATE TABLE IF NOT EXISTS ...) */ public function createTableSql($table) { $table = trim((string) $table, '`'); $row = $this->query( 'SELECT sql FROM sqlite_master WHERE type=' . $this->escape('table') . ' AND name=' . $this->escape($table) . ' COLLATE NOCASE' )->getRowArray(); if (! $row || empty($row['sql'])) { return ''; } $sql = trim($row['sql']); if (stripos($sql, 'IF NOT EXISTS') === false) { $sql = preg_replace('/^CREATE\s+TABLE\b/i', 'CREATE TABLE IF NOT EXISTS', $sql) ?? $sql; } return $sql; } /** * 获取表完整字段信息(兼容 SHOW FULL COLUMNS 结果键名) */ public function showFullColunms($table) { $table = trim((string) $table, '`'); $rows = $this->query( 'PRAGMA TABLE_INFO(' . $this->protectIdentifiers($table, true, null, false) . ')' )->getResultArray(); $out = []; foreach ($rows as $r) { $out[] = [ 'Field' => $r['name'] ?? '', 'Type' => $r['type'] ?? '', 'Collation' => null, 'Null' => empty($r['notnull']) ? 'YES' : 'NO', 'Key' => ! empty($r['pk']) ? 'PRI' : '', 'Default' => $r['dflt_value'] ?? null, 'Extra' => '', 'Privileges' => '', 'Comment' => '', ]; } return $out; } /** * 获取全部表状态(兼容 SHOW TABLE STATUS 常用键) */ public function showTableStatus() { $tables = $this->query( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" )->getResultArray(); $out = []; foreach ($tables as $t) { $name = $t['name']; $cnt = $this->query('SELECT COUNT(*) AS c FROM ' . $this->escapeIdentifiers($name))->getRowArray(); $out[] = [ 'Name' => $name, 'Engine' => 'SQLite3', 'Version' => null, 'Row_format' => null, 'Rows' => isset($cnt['c']) ? (int) $cnt['c'] : 0, 'Avg_row_length' => null, 'Data_length' => 0, 'Max_data_length' => null, 'Index_length' => 0, 'Data_free' => 0, 'Auto_increment' => null, 'Create_time' => null, 'Update_time' => null, 'Check_time' => null, 'Collation' => 'BINARY', 'Checksum' => null, 'Create_options' => null, 'Comment' => '', ]; } return $out; } /** * 修复表(SQLite 无对应操作,返回 true) */ public function repairTable($table) { return true; } /** * 优化表 → VACUUM(库级,忽略单表名) */ public function optimizeTable($table) { return $this->query('VACUUM'); } /** * 刷新表(无操作) */ public function flushTable($table) { return true; } /** * 检查表 → integrity_check,返回兼容 Msg_text 的一行 */ public function checkTable($table) { $row = $this->query('PRAGMA integrity_check')->getRowArray(); $msg = is_array($row) ? (string) reset($row) : 'unknown'; return [ 'Table' => trim((string) $table, '`'), 'Op' => 'check', 'Msg_type' => strtolower($msg) === 'ok' ? 'status' : 'error', 'Msg_text' => $msg, ]; } /** * 修改表字符集(SQLite 固定 UTF-8,无操作) */ public function setTableCharset($table, $charset = 'utf8mb4', $collate = 'utf8mb4_unicode_ci') { return true; } // -------------------------------------------------------------------- // 迅睿 DDL 内部辅助 // -------------------------------------------------------------------- /** * 将 MySQL CREATE TABLE 转为 SQLite(KEY 拆成后续 CREATE INDEX) */ protected function _xrFormatCreateTableSql(string $sql): string { // 去掉表尾 ENGINE/CHARSET/COLLATE/COMMENT $sql = preg_replace( '/\)\s*ENGINE\s*=\s*\w+[^;]*$/i', ')', $sql ) ?? $sql; $sql = preg_replace('/\)\s*DEFAULT\s+CHARSET\s*=\s*\w+[^;]*$/i', ')', $sql) ?? $sql; $sql = preg_replace('/\)\s*COMMENT\s*=\s*\'(?:\'\'|[^\'])*\'\s*$/i', ')', $sql) ?? $sql; $sql = preg_replace('/\s+CHARACTER\s+SET\s+\w+/i', '', $sql) ?? $sql; $sql = preg_replace('/\s+COLLATE\s+\w+/i', '', $sql) ?? $sql; if (! preg_match('/^(CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+)([^\s(]+)(\s*\(.*\))\s*$/is', $sql, $m)) { // 宽松:仍做列级替换 return $this->_xrNormalizeMysqlColumnLine($sql); } $prefix = $m[1]; $table = trim($m[2], '`"'); $body = trim($m[3]); $body = substr($body, 1, -1); // 去括号 $parts = $this->_xrSplitSqlColumns($body); $cols = []; $idxs = []; $pkInline = false; foreach ($parts as $part) { $part = trim($part); if ($part === '') { continue; } if (preg_match('/^PRIMARY\s+KEY/i', $part)) { if (! $pkInline) { $cols[] = $part; } continue; } if (preg_match('/^UNIQUE(?:\s+KEY|\s+INDEX)?\s+(?:[`"]?\w+[`]?\s+)?\((.+)\)/i', $part, $um)) { $cols[] = 'UNIQUE (' . $this->_xrStripMysqlIndexPrefix($um[1]) . ')'; continue; } if (preg_match('/^(?:KEY|INDEX)\s+(?:[`"]?(\w+)[`]?\s+)?\((.+)\)/i', $part, $km)) { $idxs[] = [ 'name' => $km[1] ?? '', 'cols' => $this->_xrStripMysqlIndexPrefix($km[2]), ]; continue; } if (preg_match('/^CONSTRAINT\b/i', $part)) { // 跳过复杂约束或保留 UNIQUE/PK 子句 if (preg_match('/PRIMARY\s+KEY/i', $part) || preg_match('/UNIQUE/i', $part)) { $cols[] = $part; } continue; } // 普通列:`name` def 或 name def if (preg_match('/^[`"]?(\w+)[`"]?\s+(.+)$/s', $part, $cm)) { $mapped = $this->_xrNormalizeMysqlColumnLine($cm[2]); if (preg_match('/PRIMARY\s+KEY/i', $mapped) && preg_match('/AUTOINCREMENT/i', $mapped)) { $pkInline = true; } $cols[] = $this->escapeIdentifiers($cm[1]) . ' ' . $mapped; } else { $cols[] = $this->_xrNormalizeMysqlColumnLine($part); } } $create = $prefix . $this->escapeIdentifiers($table) . " ( " . implode(",\n", $cols) . ' )'; foreach ($idxs as $i => $ix) { $idxName = $ix['name'] !== '' ? $ix['name'] : ($table . '_idx_' . $i); $idxName = preg_replace('/[^a-zA-Z0-9_]/', '_', $idxName); $create .= ";\nCREATE INDEX IF NOT EXISTS " . $this->escapeIdentifiers($idxName) . ' ON ' . $this->escapeIdentifiers($table) . ' (' . $ix['cols'] . ')'; } return $create; } /** * 去掉 MySQL 索引列前缀长度:`col`(191) / col(20) → col * SQLite 会把 col(20) 解析成函数调用,导致 no such function: col */ protected function _xrStripMysqlIndexPrefix(string $cols): string { return preg_replace('/([`"\']?\w+[`"\']?)\s*\(\s*\d+\s*\)/', '$1', $cols) ?? $cols; } /** * 按逗号拆分 CREATE 括号内定义(忽略括号内逗号) */ protected function _xrSplitSqlColumns(string $body): array { $parts = []; $buf = ''; $depth = 0; $len = strlen($body); $quote = null; for ($i = 0; $i < $len; $i++) { $ch = $body[$i]; if ($quote !== null) { $buf .= $ch; if ($ch === $quote) { // 处理 '' 转义 if ($ch === "'" && $i + 1 < $len && $body[$i + 1] === "'") { $buf .= $body[++$i]; continue; } $quote = null; } continue; } if ($ch === "'" || $ch === '"' || $ch === '`') { $quote = $ch; $buf .= $ch; continue; } if ($ch === '(') { $depth++; $buf .= $ch; continue; } if ($ch === ')') { $depth--; $buf .= $ch; continue; } if ($ch === ',' && $depth === 0) { $parts[] = trim($buf); $buf = ''; continue; } $buf .= $ch; } if (trim($buf) !== '') { $parts[] = trim($buf); } return $parts; } /** * 规范化单列 MySQL 定义片段为 SQLite */ protected function _xrNormalizeMysqlColumnLine(string $def): string { $def = preg_replace("/\s+COMMENT\s+'((?:''|[^'])*)'/i", '', $def) ?? $def; $def = preg_replace('/\s+COMMENT\s+"((?:""|[^"])*)"/i', '', $def) ?? $def; $def = preg_replace('/\bUNSIGNED\b/i', '', $def) ?? $def; $def = preg_replace('/\bZEROFILL\b/i', '', $def) ?? $def; $def = preg_replace('/\bCHARACTER\s+SET\s+\w+/i', '', $def) ?? $def; $def = preg_replace('/\bCOLLATE\s+\w+/i', '', $def) ?? $def; $def = preg_replace('/\b(LONGTEXT|MEDIUMTEXT|TINYTEXT)\b/i', 'TEXT', $def) ?? $def; $def = preg_replace('/\bDOUBLE(?:\s*\([^)]*\))?/i', 'REAL', $def) ?? $def; $def = preg_replace('/\bFLOAT(?:\s*\([^)]*\))?/i', 'REAL', $def) ?? $def; $def = preg_replace('/\bDECIMAL(?:\s*\([^)]*\))?/i', 'NUMERIC', $def) ?? $def; $def = preg_replace('/\bDATETIME\b/i', 'TEXT', $def) ?? $def; $def = preg_replace('/\bTIMESTAMP\b/i', 'TEXT', $def) ?? $def; $def = preg_replace('/\bENUM\s*\([^)]*\)/i', 'TEXT', $def) ?? $def; $def = preg_replace('/\bSET\s*\([^)]*\)/i', 'TEXT', $def) ?? $def; $hasAi = (bool) preg_match('/\bAUTO_INCREMENT\b/i', $def); $def = preg_replace('/\bAUTO_INCREMENT\b/i', '', $def) ?? $def; // int(N) / tinyint 等 → INTEGER $def = preg_replace('/\b(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?\s*\(\d+\)/i', 'INTEGER', $def) ?? $def; $def = preg_replace('/\b(?:TINY|SMALL|MEDIUM|BIG)?INT\b/i', 'INTEGER', $def) ?? $def; if ($hasAi) { // SQLite:AUTOINCREMENT 仅用于 INTEGER PRIMARY KEY return 'INTEGER PRIMARY KEY AUTOINCREMENT'; } return trim(preg_replace('/\s+/', ' ', $def) ?? $def); } /** * 仅映射类型名(不含属性) */ protected function _xrMapMysqlTypeOnly(string $type): string { $type = trim($type); $tmp = $this->_xrNormalizeMysqlColumnLine($type); if (preg_match('/^(\S+)/', $tmp, $m)) { return $m[1]; } return $type; } // -------------------------------------------------------------------- // 字段搜索 WHERE 表达式(覆盖 Model 默认 MySQL 写法) // -------------------------------------------------------------------- /** * FIND_IN_SET → SQLite 逗号包围 LIKE */ public function whereFindInSet($column, $value) { $column = (string) $column; if (dr_is_numeric($value)) { $safe = (string) (int) $value; } else { $safe = str_replace(["\\", "'"], ["\\\\", "''"], dr_safe_replace($value)); } return "(',' || IFNULL(" . $column . ",'') || ',') LIKE '%," . $safe . ",%'"; } /** * JSON 包含 → SQLite LIKE 兼容 */ public function whereJson($table, $name, $value) { if (strpos($name, '`') === false && strpos($name, '"') === false) { $name = $table ? $this->escapeIdentifiers($table) . '.' . $this->escapeIdentifiers($name) : $this->escapeIdentifiers($name); } return $name . ' LIKE \'%"' . $value . '"%\''; } // -------------------------------------------------------------------- // 聚合 SELECT(供 Model::select_agg / select_count / select_sum 中转) // -------------------------------------------------------------------- /** * 混合 SELECT:'form_id, COUNT(*) AS cnt, SUM(price) AS total' * SQLite 用双引号标识符(escapeChar=") */ public function buildSelectAgg($select) { $parts = $this->_xrSplitSelectParts((string) $select); $out = []; foreach ($parts as $part) { $part = trim($part); if ($part === '') { continue; } if (preg_match('/^(COUNT|SUM|AVG|MAX|MIN)\s*\(\s*(.+?)\s*\)\s*(?:AS\s+([`"]?[\w]+[`"]?))?\s*$/i', $part, $m)) { $out[] = $this->buildSelectFunc(strtoupper($m[1]), trim($m[2]), isset($m[3]) ? trim($m[3], '`"[]') : ''); continue; } if (preg_match('/^(.+?)\s+AS\s+([`"]?[\w]+[`"]?)\s*$/i', $part, $m)) { $out[] = $this->_xrQuoteSelectIdent(trim($m[1])) . ' AS ' . $this->escapeIdentifiers(trim($m[2], '`"[]')); continue; } $out[] = $this->_xrQuoteSelectIdent($part); } return implode(', ', $out); } /** * 单个聚合:COUNT/SUM/AVG/MAX/MIN */ public function buildSelectFunc($fn, $field, $alias = '') { $fn = strtoupper(trim((string) $fn)); $field = trim((string) $field); $alias = trim((string) $alias, '`"[]'); if (! in_array($fn, ['COUNT', 'SUM', 'AVG', 'MAX', 'MIN'], true) || $field === '') { return ''; } $inner = ($field === '*') ? '*' : $this->_xrQuoteSelectIdent($field); $sql = $fn . '(' . $inner . ')'; if ($alias !== '') { $sql .= ' AS ' . $this->escapeIdentifiers($alias); } return $sql; } /** * 列标识加引(table.field / *) */ protected function _xrQuoteSelectIdent($expr) { $expr = trim((string) $expr); if ($expr === '' || $expr === '*') { return $expr === '*' ? '*' : ''; } $expr = trim($expr, '`"[]'); if (strpos($expr, '.') !== false) { $bits = explode('.', $expr); foreach ($bits as &$b) { $b = $this->escapeIdentifiers(trim($b, '`"[]')); } return implode('.', $bits); } return $this->escapeIdentifiers($expr); } /** * 按逗号拆 SELECT(忽略括号/引号内逗号) */ protected function _xrSplitSelectParts($select) { $parts = []; $buf = ''; $depth = 0; $len = strlen($select); $quote = null; for ($i = 0; $i < $len; $i++) { $ch = $select[$i]; if ($quote !== null) { $buf .= $ch; if ($ch === $quote) { if (($ch === "'" || $ch === '"') && $i + 1 < $len && $select[$i + 1] === $ch) { $buf .= $select[++$i]; continue; } $quote = null; } continue; } if ($ch === "'" || $ch === '"' || $ch === '`') { $quote = $ch; $buf .= $ch; continue; } if ($ch === '(') { $depth++; $buf .= $ch; continue; } if ($ch === ')') { $depth = max(0, $depth - 1); $buf .= $ch; continue; } if ($ch === ',' && $depth === 0) { $parts[] = $buf; $buf = ''; continue; } $buf .= $ch; } if (trim($buf) !== '') { $parts[] = $buf; } return $parts; } }