1356 lines
43 KiB
PHP
1356 lines
43 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* This file is part of CodeIgniter 4 framework.
|
||
*
|
||
* (c) CodeIgniter Foundation <admin@codeigniter.com>
|
||
*
|
||
* 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<SQLite3, SQLite3Result>
|
||
*/
|
||
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<string>
|
||
*
|
||
* @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<stdClass>
|
||
*
|
||
* @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<string, stdClass>
|
||
*
|
||
* @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<string, stdClass>
|
||
*/
|
||
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<string, int|string>
|
||
*/
|
||
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;
|
||
}
|
||
}
|