Files
MyGoNavi/internal/app/explain_parse_sqlite.go
Syngnat 1a8effb51f 🐛 fix(export,ssh,secret,explain): 修复 SQL 导出注入与六处静默数据丢失/资源泄漏
- SQL 导出:formatSQLValue 按方言转义反斜杠,修复 MySQL 系 dump 还原时静默改写数据、
  且以反斜杠结尾的值吞掉闭合引号致源库恶意行可执行任意 SQL 的问题;非 MySQL 方言保持原样
- 明文凭据:daily_secrets.json 由 0o644 改为 0o600、目录改 0o700,并对历史文件显式 Chmod
- SSH 隧道:RegisterSSHNetwork 改为确定性 network 名并复用缓存客户端,消除驱动全局 dialer
  表随重连线性增长、永久钉住 ssh.Client 的连接与 goroutine 泄漏
- xlsx 导入:单元格 r 属性列号增加 OOXML 16384 上限并提前熔断,避免篡改文件放大分配致 OOM
- 数据导出:三处非 SQL 导出入口捕获 file.Close 错误,不再在落盘失败时返回“导出成功”
- 数据根迁移:copyFile 捕获 Close 错误并补 Sync,避免被截断的副本被判定为迁移成功
- 执行计划:节点 ID 改为按 ExplainResult 派生,移除进程级全局计数器与 reset,
  修复并发诊断互相踩踏编号导致的重复 node ID 与错挂父子边
- 补充 5 个回归测试文件,含“导出→切分”闭环断言与未转义时的反向对照
2026-07-26 19:40:33 +08:00

241 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"fmt"
"strconv"
"strings"
"GoNavi-Wails/internal/connection"
)
// SQLite EXPLAIN QUERY PLAN 解析。
//
// SQLite EQP 输出是 4 列表格:
//
// id | parent | notused | detail
// 2 | 0 | 0 | SCAN TABLE t
// 3 | 0 | 0 | SEARCH TABLE t USING INDEX idx_x (col=?)
// 7 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY
// 21 | 0 | 0 | COMPOUND QUERY
// 22 | 0 | 0 | USE TEMP B-TREE FOR LAST DISTINCT
//
// id 字段语义:
// - 同一 id 多行:同一节点的多个细节行(如"SCAN" + "USE TEMP B-TREE"
// - 不同 id不同节点parent 字段指向父节点 id
//
// detail 文本模式:
// - "SCAN TABLE <name>" 或 "SCAN <name>":全表扫描
// - "SEARCH TABLE <name> USING INDEX <idx> (<cols>)":索引扫描
// - "SEARCH TABLE <name> USING PRIMARY KEY (<cols>)":主键扫描
// - "USE TEMP B-TREE FOR ORDER BY"filesort
// - "USE TEMP B-TREE FOR DISTINCT":临时表
// - "COMPOUND QUERY"UNION/INTERSECT 等
// - "CORRELATED SCALAR SUBQUERY":子查询
// - "CO-ROUTINE <name>":协程
func parseSQLiteExplain(sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) {
result := connection.ExplainResult{
DBType: "sqlite",
SourceSQL: sourceSQL,
}
header, rows := parseExplainTSVRows(raw)
if len(header) == 0 || len(rows) == 0 {
return result, fmt.Errorf("SQLite EQP 输出无有效行")
}
colID := lookupTSVColumn(header, "id")
colParent := lookupTSVColumn(header, "parent")
colDetail := lookupTSVColumn(header, "detail")
if colID < 0 || colDetail < 0 {
return result, fmt.Errorf("SQLite EQP 输出缺少 id 或 detail 列")
}
// 同一 id 多行:合并 detail 后作为单节点
// 不同 id 的父子通过 parent 关联
type eqpEntry struct {
ID string
ParentID string
Details []string
NodeID string // 归一化后的 ExplainNode.ID
}
entries := make(map[string]*eqpEntry)
var order []string // 保持 id 出现顺序
for _, row := range rows {
var id, parent, detail string
if colID < len(row) {
id = strings.TrimSpace(row[colID])
}
if colParent >= 0 && colParent < len(row) {
parent = strings.TrimSpace(row[colParent])
}
if colDetail < len(row) {
detail = strings.TrimSpace(row[colDetail])
}
if id == "" {
continue
}
entry, exists := entries[id]
if !exists {
entry = &eqpEntry{ID: id, ParentID: parent}
entries[id] = entry
order = append(order, id)
}
if detail != "" {
entry.Details = append(entry.Details, detail)
}
}
// 按 id 出现顺序生成节点SQLite 保证父先于子)
for _, id := range order {
entry := entries[id]
node := buildSQLiteNodeFromDetails(entry.Details)
parentNodeID := ""
if entry.ParentID != "" && entry.ParentID != "0" {
if parent, ok := entries[entry.ParentID]; ok && parent.NodeID != "" {
parentNodeID = parent.NodeID
}
}
entry.NodeID = appendExplainChild(&result, parentNodeID, node)
}
result.RawFormat = connection.ExplainFormatTable
result.RawPayload = raw
finalizeExplainStats(&result)
return result, nil
}
// buildSQLiteNodeFromDetails 把 SQLite EQP 的多个 detail 行合并为单节点。
// 第一行通常是主操作SCAN/SEARCH后续行是附加标志USE TEMP B-TREE 等)。
//
// 注意SQLite 在某些场景下 "USE TEMP B-TREE ..." 会作为独立 id 出现(不是 SCAN 的附加行),
// 此时主操作本身就是 USE TEMP B-TREE需要识别为附加 flag 节点OpType 保持 OTHER
func buildSQLiteNodeFromDetails(details []string) connection.ExplainNode {
node := connection.ExplainNode{OpType: connection.ExplainOpOther}
if len(details) == 0 {
return node
}
// 主操作从第一行解析
primary := details[0]
node.OpDetail = primary
lower := strings.ToLower(primary)
switch {
case strings.HasPrefix(lower, "scan"):
node.OpType = connection.ExplainOpScan
node.Table = extractSQLiteTableName(primary)
node.Flags = append(node.Flags, connection.ExplainFlagFullScan, connection.ExplainFlagNoIndex)
case strings.HasPrefix(lower, "search"):
node.OpType = classifySQLiteSearchOp(primary)
node.Table = extractSQLiteTableName(primary)
node.Index = extractSQLiteIndexName(primary)
case strings.HasPrefix(lower, "compound"):
node.OpType = connection.ExplainOpUnion
case strings.HasPrefix(lower, "correlated"), strings.HasPrefix(lower, "scalar subquery"):
node.OpType = connection.ExplainOpSubquery
case strings.HasPrefix(lower, "co-routine"):
node.OpType = connection.ExplainOpOther
case strings.HasPrefix(lower, "use temp b-tree"):
// 独立 id 形式的附加 flag 节点:直接打 flagOpType 保持 OTHER
if strings.Contains(lower, "order by") {
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
} else {
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
}
}
// 后续行是附加 flag仅当主行不是 USE TEMP B-TREE 时才处理,避免重复)
if !strings.HasPrefix(lower, "use temp b-tree") {
for _, d := range details[1:] {
dl := strings.ToLower(d)
switch {
case strings.Contains(dl, "temp b-tree"):
if strings.Contains(dl, "order by") {
node.Flags = append(node.Flags, connection.ExplainFlagFilesort)
} else {
node.Flags = append(node.Flags, connection.ExplainFlagTempTable)
}
case strings.Contains(dl, "subquery"):
node.Flags = append(node.Flags, "SUBQUERY")
}
if node.Extra == nil {
node.Extra = map[string]any{}
}
node.Extra["extra"] = d
}
}
return node
}
// classifySQLiteSearchOp 区分 SQLite SEARCH 的索引类型。
// USING INDEX → INDEX_SCANUSING PRIMARY KEY → INDEX_SCANUSING ROWID → SCAN伪索引扫描
func classifySQLiteSearchOp(detail string) string {
lower := strings.ToLower(detail)
if strings.Contains(lower, "using covering index") {
return connection.ExplainOpIndexOnly
}
if strings.Contains(lower, "using index") || strings.Contains(lower, "using primary key") {
return connection.ExplainOpIndexScan
}
if strings.Contains(lower, "using rowid") {
// ROWID 扫描本质还是按物理位置顺序访问
return connection.ExplainOpScan
}
return connection.ExplainOpIndexScan
}
// extractSQLiteTableName 从 detail 文本中提取表名。
// 形如 "SCAN TABLE users" → "users""SEARCH TABLE users USING INDEX idx_x (id)" → "users"。
func extractSQLiteTableName(detail string) string {
upper := strings.ToUpper(detail)
for _, marker := range []string{"TABLE ", "VIEW "} {
idx := strings.Index(upper, marker)
if idx < 0 {
continue
}
rest := detail[idx+len(marker):]
// 截到下一个空格或 USING 之前
for i, ch := range rest {
if ch == ' ' || ch == '\t' {
return strings.TrimSpace(rest[:i])
}
}
return strings.TrimSpace(rest)
}
return ""
}
// extractSQLiteIndexName 从 detail 中提取使用的索引名。
// 形如 "USING INDEX idx_x (id)" → "idx_x""USING PRIMARY KEY" → "PRIMARY"。
func extractSQLiteIndexName(detail string) string {
upper := strings.ToUpper(detail)
for _, marker := range []string{"USING INDEX ", "USING PRIMARY KEY", "USING COVERING INDEX "} {
idx := strings.Index(upper, marker)
if idx < 0 {
continue
}
rest := detail[idx+len(marker):]
if marker == "USING PRIMARY KEY" {
return "PRIMARY"
}
// 截到下一个空格或左括号
for i, ch := range rest {
if ch == ' ' || ch == '\t' || ch == '(' {
if i == 0 {
return ""
}
name := strings.TrimSpace(rest[:i])
if _, err := strconv.Atoi(name); err == nil {
continue
}
return name
}
}
return strings.TrimSpace(rest)
}
return ""
}