* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\Database\MySQLi; use CodeIgniter\Database\BaseConnection; use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Database\TableName; use CodeIgniter\Exceptions\LogicException; use mysqli; use mysqli_result; use mysqli_sql_exception; use stdClass; use Throwable; /** * Connection for MySQLi * * @extends BaseConnection */ class Connection extends BaseConnection { /** * Database driver * * @var string */ public $DBDriver = 'MySQLi'; /** * DELETE hack flag * * Whether to use the MySQL "delete hack" which allows the number * of affected rows to be shown. Uses a preg_replace when enabled, * adding a bit more processing to all queries. * * @var bool */ public $deleteHack = true; /** * Identifier escape character * * @var string */ public $escapeChar = '`'; /** * MySQLi object * * Has to be preserved without being assigned to $connId. * * @var false|mysqli */ public $mysqli; /** * MySQLi constant * * For unbuffered queries use `MYSQLI_USE_RESULT`. * * Default mode for buffered queries uses `MYSQLI_STORE_RESULT`. * * @var int */ public $resultMode = MYSQLI_STORE_RESULT; /** * Use MYSQLI_OPT_INT_AND_FLOAT_NATIVE * * @var bool */ public $numberNative = false; /** * Use MYSQLI_CLIENT_FOUND_ROWS * * Whether affectedRows() should return number of rows found, * or number of rows changed, after an UPDATE query. * * @var bool */ public $foundRows = false; /** * Connect to the database. * * @return false|mysqli * * @throws DatabaseException */ public function connect(bool $persistent = false) { // Do we have a socket path? if ($this->hostname[0] === '/') { $hostname = null; $port = null; $socket = $this->hostname; } else { $hostname = $persistent ? 'p:' . $this->hostname : $this->hostname; $port = empty($this->port) ? null : $this->port; $socket = ''; } $clientFlags = ($this->compress === true) ? MYSQLI_CLIENT_COMPRESS : 0; $this->mysqli = mysqli_init(); mysqli_report(MYSQLI_REPORT_ALL & ~MYSQLI_REPORT_INDEX); $this->mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 10); if ($this->numberNative === true) { $this->mysqli->options(MYSQLI_OPT_INT_AND_FLOAT_NATIVE, 1); } if (isset($this->strictOn)) { if ($this->strictOn) { $this->mysqli->options( MYSQLI_INIT_COMMAND, "SET SESSION sql_mode = CONCAT(@@sql_mode, ',', 'STRICT_ALL_TABLES')" ); } else { $this->mysqli->options( MYSQLI_INIT_COMMAND, "SET SESSION sql_mode = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( @@sql_mode, 'STRICT_ALL_TABLES,', ''), ',STRICT_ALL_TABLES', ''), 'STRICT_ALL_TABLES', ''), 'STRICT_TRANS_TABLES,', ''), ',STRICT_TRANS_TABLES', ''), 'STRICT_TRANS_TABLES', '')" ); } } if (is_array($this->encrypt)) { $ssl = []; if (! empty($this->encrypt['ssl_key'])) { $ssl['key'] = $this->encrypt['ssl_key']; } if (! empty($this->encrypt['ssl_cert'])) { $ssl['cert'] = $this->encrypt['ssl_cert']; } if (! empty($this->encrypt['ssl_ca'])) { $ssl['ca'] = $this->encrypt['ssl_ca']; } if (! empty($this->encrypt['ssl_capath'])) { $ssl['capath'] = $this->encrypt['ssl_capath']; } if (! empty($this->encrypt['ssl_cipher'])) { $ssl['cipher'] = $this->encrypt['ssl_cipher']; } if ($ssl !== []) { if (isset($this->encrypt['ssl_verify'])) { if ($this->encrypt['ssl_verify']) { if (defined('MYSQLI_OPT_SSL_VERIFY_SERVER_CERT')) { $this->mysqli->options(MYSQLI_OPT_SSL_VERIFY_SERVER_CERT, 1); } } // Apparently (when it exists), setting MYSQLI_OPT_SSL_VERIFY_SERVER_CERT // to FALSE didn't do anything, so PHP 5.6.16 introduced yet another // constant ... // // https://secure.php.net/ChangeLog-5.php#5.6.16 // https://bugs.php.net/bug.php?id=68344 elseif (defined('MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT') && version_compare($this->mysqli->client_info, 'mysqlnd 5.6', '>=')) { $clientFlags += MYSQLI_CLIENT_SSL_DONT_VERIFY_SERVER_CERT; } } $this->mysqli->ssl_set( $ssl['key'] ?? null, $ssl['cert'] ?? null, $ssl['ca'] ?? null, $ssl['capath'] ?? null, $ssl['cipher'] ?? null ); } $clientFlags += MYSQLI_CLIENT_SSL; } if ($this->foundRows) { $clientFlags += MYSQLI_CLIENT_FOUND_ROWS; } try { if ($this->mysqli->real_connect( $hostname, $this->username, $this->password, $this->database, $port, $socket, $clientFlags )) { // Prior to version 5.7.3, MySQL silently downgrades to an unencrypted connection if SSL setup fails if (($clientFlags & MYSQLI_CLIENT_SSL) !== 0 && version_compare($this->mysqli->client_info, 'mysqlnd 5.7.3', '<=') && empty($this->mysqli->query("SHOW STATUS LIKE 'ssl_cipher'")->fetch_object()->Value) ) { $this->mysqli->close(); $message = 'MySQLi was configured for an SSL connection, but got an unencrypted connection instead!'; log_message('error', $message); if ($this->DBDebug) { throw new DatabaseException($message); } return false; } if (! $this->mysqli->set_charset($this->charset)) { log_message('error', "Database: Unable to set the configured connection charset ('{$this->charset}')."); $this->mysqli->close(); if ($this->DBDebug) { throw new DatabaseException('Unable to set client connection character set: ' . $this->charset); } return false; } return $this->mysqli; } } catch (Throwable $e) { // Clean sensitive information from errors. $msg = $e->getMessage(); $msg = str_replace($this->username, '****', $msg); $msg = str_replace($this->password, '****', $msg); throw new DatabaseException($msg, $e->getCode(), $e); } return false; } /** * 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 { if ($databaseName === '') { $databaseName = $this->database; } if (empty($this->connID)) { $this->initialize(); } if ($this->connID->select_db($databaseName)) { $this->database = $databaseName; return true; } 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']; } if (empty($this->mysqli)) { $this->initialize(); } return $this->dataCache['version'] = $this->mysqli->server_info; } /** * Executes the query against the database. * * @return false|mysqli_result */ protected function execute(string $sql) { while ($this->connID->more_results()) { $this->connID->next_result(); if ($res = $this->connID->store_result()) { $res->free(); } } try { return $this->connID->query($this->prepQuery($sql), $this->resultMode); } catch (mysqli_sql_exception $e) { log_message('error', (string) $e); if ($this->DBDebug) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } } return false; } /** * Prep the query. If needed, each database adapter can prep the query string */ protected function prepQuery(string $sql): string { // mysqli_affected_rows() returns 0 for "DELETE FROM TABLE" queries. This hack // modifies the query so that it a proper number of affected rows is returned. if ($this->deleteHack === true && preg_match('/^\s*DELETE\s+FROM\s+(\S+)\s*$/i', $sql)) { return trim($sql) . ' WHERE 1=1'; } return $sql; } /** * Returns the total number of rows affected by this query. */ public function affectedRows(): int { return $this->connID->affected_rows ?? 0; } /** * Platform-dependant string escape */ protected function _escapeString(string $str): string { if (! $this->connID) { $this->initialize(); } return $this->connID->real_escape_string($str); } /** * Escape Like String Direct * There are a few instances where MySQLi queries cannot take the * additional "ESCAPE x" parameter for specifying the escape character * in "LIKE" strings, and this handles those directly with a backslash. * * @param list|string $str Input string * * @return list|string */ public function escapeLikeStringDirect($str) { if (is_array($str)) { foreach ($str as $key => $val) { $str[$key] = $this->escapeLikeStringDirect($val); } return $str; } $str = $this->_escapeString($str); // Escape LIKE condition wildcards return str_replace( [$this->likeEscapeChar, '%', '_'], ['\\' . $this->likeEscapeChar, '\\%', '\\_'], $str ); } /** * Generates the SQL for listing tables in a platform-dependent manner. * Uses escapeLikeStringDirect(). * * @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 { $sql = 'SHOW TABLES FROM ' . $this->escapeIdentifier($this->database); if ((string) $tableName !== '') { return $sql . ' LIKE ' . $this->escape($tableName); } if ($prefixLimit && $this->DBPrefix !== '') { return $sql . " LIKE '" . $this->escapeLikeStringDirect($this->DBPrefix) . "%'"; } return $sql; } /** * Generates a platform-specific query string so that the column names can be fetched. * * @param string|TableName $table */ protected function _listColumns($table = ''): string { $tableName = $this->protectIdentifiers( $table, true, null, false ); return 'SHOW COLUMNS FROM ' . $tableName; } /** * Returns an array of objects with field data * * @return list * * @throws DatabaseException */ protected function _fieldData(string $table): array { $table = $this->protectIdentifiers($table, true, null, false); if (($query = $this->query('SHOW COLUMNS FROM ' . $table)) === false) { throw new DatabaseException(lang('Database.failGetFieldData')); } $query = $query->getResultObject(); $retVal = []; for ($i = 0, $c = count($query); $i < $c; $i++) { $retVal[$i] = new stdClass(); $retVal[$i]->name = $query[$i]->Field; sscanf($query[$i]->Type, '%[a-z](%d)', $retVal[$i]->type, $retVal[$i]->max_length); $retVal[$i]->nullable = $query[$i]->Null === 'YES'; $retVal[$i]->default = $query[$i]->Default; $retVal[$i]->primary_key = (int) ($query[$i]->Key === 'PRI'); } return $retVal; } /** * Returns an array of objects with index data * * @return array * * @throws DatabaseException * @throws LogicException */ protected function _indexData(string $table): array { $table = $this->protectIdentifiers($table, true, null, false); if (($query = $this->query('SHOW INDEX FROM ' . $table)) === false) { throw new DatabaseException(lang('Database.failGetIndexData')); } $indexes = $query->getResultArray(); if ($indexes === []) { return []; } $keys = []; foreach ($indexes as $index) { if (empty($keys[$index['Key_name']])) { $keys[$index['Key_name']] = new stdClass(); $keys[$index['Key_name']]->name = $index['Key_name']; if ($index['Key_name'] === 'PRIMARY') { $type = 'PRIMARY'; } elseif ($index['Index_type'] === 'FULLTEXT') { $type = 'FULLTEXT'; } elseif ($index['Non_unique']) { $type = $index['Index_type'] === 'SPATIAL' ? 'SPATIAL' : 'INDEX'; } else { $type = 'UNIQUE'; } $keys[$index['Key_name']]->type = $type; } $keys[$index['Key_name']]->fields[] = $index['Column_name']; } return $keys; } /** * Returns an array of objects with Foreign key data * * @return array * * @throws DatabaseException */ protected function _foreignKeyData(string $table): array { $sql = ' SELECT tc.CONSTRAINT_NAME, tc.TABLE_NAME, kcu.COLUMN_NAME, rc.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME, rc.DELETE_RULE, rc.UPDATE_RULE, rc.MATCH_OPTION FROM information_schema.table_constraints AS tc INNER JOIN information_schema.referential_constraints AS rc ON tc.constraint_name = rc.constraint_name AND tc.constraint_schema = rc.constraint_schema INNER JOIN information_schema.key_column_usage AS kcu ON tc.constraint_name = kcu.constraint_name AND tc.constraint_schema = kcu.constraint_schema WHERE tc.constraint_type = ' . $this->escape('FOREIGN KEY') . ' AND tc.table_schema = ' . $this->escape($this->database) . ' AND tc.table_name = ' . $this->escape($table); if (($query = $this->query($sql)) === false) { throw new DatabaseException(lang('Database.failGetForeignKeyData')); } $query = $query->getResultObject(); $indexes = []; foreach ($query as $row) { $indexes[$row->CONSTRAINT_NAME]['constraint_name'] = $row->CONSTRAINT_NAME; $indexes[$row->CONSTRAINT_NAME]['table_name'] = $row->TABLE_NAME; $indexes[$row->CONSTRAINT_NAME]['column_name'][] = $row->COLUMN_NAME; $indexes[$row->CONSTRAINT_NAME]['foreign_table_name'] = $row->REFERENCED_TABLE_NAME; $indexes[$row->CONSTRAINT_NAME]['foreign_column_name'][] = $row->REFERENCED_COLUMN_NAME; $indexes[$row->CONSTRAINT_NAME]['on_delete'] = $row->DELETE_RULE; $indexes[$row->CONSTRAINT_NAME]['on_update'] = $row->UPDATE_RULE; $indexes[$row->CONSTRAINT_NAME]['match'] = $row->MATCH_OPTION; } return $this->foreignKeyDataToObjects($indexes); } /** * Returns platform-specific SQL to disable foreign key checks. * * @return string */ protected function _disableForeignKeyChecks() { return 'SET FOREIGN_KEY_CHECKS=0'; } /** * Returns platform-specific SQL to enable foreign key checks. * * @return string */ protected function _enableForeignKeyChecks() { return 'SET FOREIGN_KEY_CHECKS=1'; } /** * 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 { if (! empty($this->mysqli->connect_errno)) { return [ 'code' => $this->mysqli->connect_errno, 'message' => $this->mysqli->connect_error, ]; } return [ 'code' => $this->connID->errno, 'message' => $this->connID->error, ]; } /** * Insert ID */ public function insertID(): int { return $this->connID->insert_id; } /** * Begin Transaction */ protected function _transBegin(): bool { $this->connID->autocommit(false); return $this->connID->begin_transaction(); } /** * Commit Transaction */ protected function _transCommit(): bool { if ($this->connID->commit()) { $this->connID->autocommit(true); return true; } return false; } /** * Rollback Transaction */ protected function _transRollback(): bool { if ($this->connID->rollback()) { $this->connID->autocommit(true); return true; } return false; } // -------------------------------------------------------------------- // 迅睿 DDL API(供 Fcms/Model/Table 等调用,便于多库驱动替换) // -------------------------------------------------------------------- /** * 规范化建表/DDL SQL(Install.sql 等以 MySQL 方言为源) * * @param string $sql * * @return string */ public function formatCreateSql($sql) { if ($sql === '' || $sql === null) { return ''; } // Install.sql 等以 MySQL 为源:兼容旧 CHARSET=utf8,并规整 ENGINE 多余空格 $sql = str_replace('ENGINE=InnoDB ', 'ENGINE=InnoDB ', $sql); $sql = str_replace( 'CHARSET=utf8 ', 'CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci ', $sql ); return trim($sql); } /** * 生成「添加字段」SQL(表名占位符 {tablename},供字段系统批量替换) * * @param string $name 字段名 * @param string $type 字段类型,如 VARCHAR(255) * @param string $info 附加属性,如 NULL DEFAULT NULL * @param string $note 字段注释(空则不输出 COMMENT) * * @return string */ public function sqlAddField($name, $type, $info, $note = '') { $sql = 'ALTER TABLE `{tablename}` ADD ' . $this->escapeIdentifiers($name) . ' ' . $type . ' ' . $info; $note = (string) $note; if ($note !== '') { $sql .= " COMMENT '" . str_replace("'", "''", $note) . "'"; } return $sql; } /** * 生成「修改字段」SQL(表名占位符 {tablename}) * * @param string $name * @param string $type * @param string $info * @param string $note * * @return string */ public function sqlEditField($name, $type, $info, $note = '') { $id = $this->escapeIdentifiers($name); $sql = 'ALTER TABLE `{tablename}` CHANGE ' . $id . ' ' . $id . ' ' . $type . ' ' . $info; $note = (string) $note; if ($note !== '') { $sql .= " COMMENT '" . str_replace("'", "''", $note) . "'"; } return $sql; } /** * 生成「删除字段」SQL(表名占位符 {tablename}) * * @param string $name * * @return string */ public function sqlDropField($name) { return 'ALTER TABLE `{tablename}` DROP ' . $this->escapeIdentifiers($name); } /** * 生成「批量添加字段」SQL(一条 ALTER 多列 ADD) * * @param array $columns [['name'=>'', 'type'=>'', 'info'=>'', 'note'=>''], ...] * * @return string */ public function sqlAddFields(array $columns) { $parts = []; foreach ($columns as $col) { if (empty($col['name'])) { continue; } $part = 'ADD ' . $this->escapeIdentifiers($col['name']) . ' ' . ($col['type'] ?? '') . ' ' . ($col['info'] ?? ''); $note = isset($col['note']) ? (string) $col['note'] : ''; if ($note !== '') { $part .= " COMMENT '" . str_replace("'", "''", $note) . "'"; } $parts[] = trim($part); } if ($parts === []) { return ''; } return 'ALTER TABLE `{tablename}` ' . implode(', ', $parts); } /** * 生成「批量删除字段」SQL(一条 ALTER 多列 DROP) * * @param array $names 字段名列表 * * @return string */ public function sqlDropFields(array $names) { $parts = []; foreach ($names as $name) { if ($name === '' || $name === null) { continue; } $parts[] = 'DROP ' . $this->escapeIdentifiers($name); } if ($parts === []) { return ''; } return 'ALTER TABLE `{tablename}` ' . implode(', ', $parts); } /** * 删除表 * * @param string $table 表名(可含前缀) * @param bool $ifExists 是否使用 IF EXISTS * * @return false|ResultInterface|bool */ 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); } /** * 修改字段 * * @param string $table 表名(可含前缀) * @param string $name 字段名 * @param string $type 字段类型,如 VARCHAR(50) * @param string $info 附加属性,如 NOT NULL / DEFAULT NULL * @param string $note 字段注释 * * @return false|ResultInterface|bool */ public function editField($table, $name, $type, $info, $note) { $table = trim((string) $table, '`'); return $this->query(str_replace( '{tablename}', $table, $this->sqlEditField($name, $type, $info, $note) )); } /** * 添加字段 * * @param string $table 表名(可含前缀) * @param string $name 字段名 * @param string $type 字段类型 * @param string $info 附加属性 * @param string $note 字段注释 * * @return false|ResultInterface|bool */ public function addField($table, $name, $type, $info, $note) { $table = trim((string) $table, '`'); return $this->query(str_replace( '{tablename}', $table, $this->sqlAddField($name, $type, $info, $note) )); } /** * 删除字段 * * @param string $table 表名(可含前缀) * @param string $name 字段名 * * @return false|ResultInterface|bool */ public function dropField($table, $name) { $table = trim((string) $table, '`'); return $this->query(str_replace( '{tablename}', $table, $this->sqlDropField($name) )); } /** * 创建表 * * @param string $table 表名(可含前缀) * @param array $fields 字段定义:['字段名' => '类型及属性SQL'] 或 [0 => '完整列定义SQL'] * @param array $indexs 索引定义:['PRIMARY KEY (`id`)', 'KEY `name` (`name`)', ...] * @param string $note 表注释 * * @return false|ResultInterface|bool */ public function createTable($table, $fields, $indexs, $note) { $lines = []; if (is_array($fields)) { foreach ($fields as $name => $def) { if (is_int($name)) { $lines[] = $def; } else { $lines[] = $this->escapeIdentifiers($name) . ' ' . $def; } } } if (is_array($indexs)) { foreach ($indexs as $idx) { if ($idx) { $lines[] = $idx; } } } $sql = 'CREATE TABLE IF NOT EXISTS ' . $this->escapeIdentifiers($table) . " ( " . implode(",\n", $lines) . " ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='" . str_replace("'", "''", (string) $note) . "'"; return $this->query($sql); } /** * 获取建表 SQL(CREATE TABLE IF NOT EXISTS ...) * * @param string $table 表名(可含前缀) * * @return string */ public function createTableSql($table) { $row = $this->query('SHOW CREATE TABLE ' . $this->escapeIdentifiers($table))->getRowArray(); if (! $row || empty($row['Create Table'])) { return ''; } return str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $row['Create Table']); } /** * 获取表完整字段信息(兼容 SHOW FULL COLUMNS 结果结构) * * @param string $table 表名(可含前缀) * * @return array */ public function showFullColunms($table) { return $this->query('SHOW FULL COLUMNS FROM ' . $this->escapeIdentifiers($table))->getResultArray(); } /** * 获取全部表状态(兼容 SHOW TABLE STATUS 结果结构:Name/Engine/Rows/...) * * @return array */ public function showTableStatus() { return $this->query('SHOW TABLE STATUS')->getResultArray(); } /** * 修复表 */ public function repairTable($table) { return $this->query('REPAIR TABLE ' . $this->escapeIdentifiers($table)); } /** * 优化表 */ public function optimizeTable($table) { return $this->query('OPTIMIZE TABLE ' . $this->escapeIdentifiers($table)); } /** * 刷新表 */ public function flushTable($table) { return $this->query('FLUSH TABLE ' . $this->escapeIdentifiers($table)); } /** * 检查表,返回一行状态(如 Msg_text) * * @return array|null */ public function checkTable($table) { return $this->query('CHECK TABLE ' . $this->escapeIdentifiers($table))->getRowArray(); } /** * 修改表默认字符集/排序规则 * * @param string $table * @param string $charset 默认 utf8mb4 * @param string $collate 默认 utf8mb4_unicode_ci */ public function setTableCharset($table, $charset = 'utf8mb4', $collate = 'utf8mb4_unicode_ci') { $table = $this->escapeIdentifiers(trim((string) $table, '`')); $charset = preg_replace('/[^a-z0-9_]/i', '', (string) $charset); $collate = preg_replace('/[^a-z0-9_]/i', '', (string) $collate); return $this->query( 'ALTER TABLE ' . $table . ' DEFAULT CHARSET=' . $charset . ' COLLATE ' . $collate ); } // -------------------------------------------------------------------- // 字段搜索 WHERE 表达式(供 Model::where_* 中转;未实现则走 Model 默认 MySQL 写法) // -------------------------------------------------------------------- /** * FIND_IN_SET 语义 */ public function whereFindInSet($column, $value) { $column = (string) $column; if (dr_is_numeric($value)) { return 'FIND_IN_SET(' . intval($value) . ',' . $column . ')'; } return 'FIND_IN_SET("' . dr_safe_replace($value) . '",' . $column . ')'; } /** * JSON 包含(多选/关联等) */ public function whereJson($table, $name, $value) { if (strpos($name, '`') === false) { $name = $table ? '`' . $table . '`.`' . $name . '`' : '`' . $name . '`'; } if (version_compare($this->getVersion(), '5.7.0') < 0) { return $name . ' LIKE \'%"' . $value . '"%\''; } return "(CASE WHEN JSON_VALID({$name}) THEN JSON_CONTAINS ({$name}->'$[*]', '\"" . $value . "\"', '$') ELSE null END)"; } // -------------------------------------------------------------------- // 聚合 SELECT(供 Model::select_agg / select_count / select_sum 中转) // -------------------------------------------------------------------- /** * 混合 SELECT:'form_id, COUNT(*) AS cnt, SUM(price) AS total' */ 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; } }