source; } /** @return array> */ public function select(string $sql, array $bindings = []): array { self::assertReadOnlySql($sql); $statement = UpstreamConnection::get($this->source)->prepare($sql); $statement->execute($bindings); return $statement->fetchAll(PDO::FETCH_ASSOC); } /** @return array|null */ public function first(string $sql, array $bindings = []): ?array { $rows = $this->select($sql, $bindings); return $rows[0] ?? null; } public function scalar(string $sql, array $bindings = []): mixed { self::assertReadOnlySql($sql); $statement = UpstreamConnection::get($this->source)->prepare($sql); $statement->execute($bindings); return $statement->fetchColumn(); } public static function assertReadOnlySql(string $sql): void { $sql = trim(ltrim($sql, "\xEF\xBB\xBF")); if ($sql === '' || str_contains($sql, ';')) { throw new InvalidArgumentException('上游查询只允许单条只读 SELECT。'); } // 禁止注释,避免通过注释拆词绕过关键字检查,也便于代码审查。 if (preg_match('/--|\/\*|\*\//', $sql) === 1) { throw new InvalidArgumentException('上游查询不得包含 SQL 注释。'); } // 去掉字符串和带引号标识符后再检查关键字,避免普通文本误报。 $inspect = preg_replace("/'(?:''|[^'])*'/s", "''", $sql) ?? $sql; $inspect = preg_replace('/\[(?:\]\]|[^\]])*\]/s', '[]', $inspect) ?? $inspect; $inspect = preg_replace('/"(?:""|[^"])*"/s', '""', $inspect) ?? $inspect; $inspect = preg_replace('/`(?:``|[^`])*`/s', '``', $inspect) ?? $inspect; $inspect = strtoupper(ltrim($inspect)); if (preg_match('/^(SELECT|WITH)\b/', $inspect) !== 1) { throw new InvalidArgumentException('上游查询只允许 SELECT 或只读 CTE。'); } $forbidden = '/\b(?:INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|' . 'GRANT|REVOKE|DENY|BACKUP|RESTORE|DBCC|BULK|OPENROWSET|OPENDATASOURCE|INTO|' . 'WAITFOR|KILL|SHUTDOWN|SET_CONFIG|NEXTVAL|SETVAL|SLEEP|BENCHMARK|GET_LOCK|RELEASE_LOCK|LOAD_FILE|PG_SLEEP)\b|\bNEXT\s+VALUE\s+FOR\b/i'; if (preg_match($forbidden, $inspect) === 1) { throw new InvalidArgumentException('上游查询包含禁止的写入或高风险语法。'); } } }