// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package apps import ( "context" "encoding/json" "fmt" "io" "sort" "strconv" "strings" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) // AppsDBExecute executes SQL against an app database. // // POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式 // (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。 // // pretty 渲染 6 种形态: // - 单 SELECT:表格(列间两空格、列对齐填充) // - 空 SELECT:`(0 rows)` // - 单 DML:`✓ N row(s) `(verb 跟 sql_type:INSERT→inserted/UPDATE→updated/DELETE→deleted) // - 单 DDL:`✓ DDL executed` // - 多语句全部成功:逐条 `Statement K: ✓ ` + 末尾 `✓ N statements executed` // - 多语句部分失败:`Statement K: ✗ []` + 末尾「前序语句已落地」提示 // // 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵 // 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results / // statement_index / error_code / error_message / rolled_back / note,避免 agent 误判 // ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit // 落地,故 rolled_back=false(真机 boe 实证)。 // // JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。 // // Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。 // // SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。 // --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。 var AppsDBExecute = common.Shortcut{ Service: appsService, Command: "+db-execute", Description: "Execute SQL (SELECT / DML / DDL) against an app database", Risk: "high-risk-write", Tips: []string{ `Example: lark-cli apps +db-execute --app-id --sql "SELECT * FROM orders LIMIT 10" --yes`, `Example: lark-cli apps +db-execute --app-id --env dev --file ./migration.sql --yes`, "Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", Input: []string{common.Stdin}}, {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, {Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } sql := strings.TrimSpace(rctx.Str("sql")) file := strings.TrimSpace(rctx.Str("file")) if sql != "" && file != "" { return appsValidationError("--sql and --file are mutually exclusive"). WithParams( appsInvalidParam("--sql", "mutually exclusive with --file"), appsInvalidParam("--file", "mutually exclusive with --sql"), ) } if file != "" { data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) if err != nil { return appsValidationParamError("--file", "--file: %v", err).WithCause(err) } // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 rctx.Cmd.Flags().Set("sql", string(data)) sql = strings.TrimSpace(string(data)) } if sql == "" { return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)"). WithParams( appsInvalidParam("--sql", "one of --sql or --file is required"), appsInvalidParam("--file", "one of --sql or --file is required"), ) } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appSQLPath(appID)). Desc("Execute SQL on app database"). Params(buildDBSQLParams(rctx)). Body(buildDBSQLBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) if err != nil { return err } raw, err := rctx.CallAPITyped("POST", appSQLPath(appID), buildDBSQLParams(rctx), buildDBSQLBody(rctx)) if err != nil { return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table `; for day-to-day debugging target the dev database with `--env dev`") } // server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results, // 让 json/pretty 路径都基于同一份反序列化产物渲染。 stmts := parseSQLResult(common.GetString(raw, "result")) // 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。 // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接 // 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 data := map[string]interface{}{"results": stmts} // 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。 // 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据 // 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。 // pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。 if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { if rctx.Format == "pretty" { renderSQLPretty(rctx.IO().Out, stmts) return output.PartialFailure(output.ExitAPI) } return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil) } rctx.OutFormat(data, nil, func(w io.Writer) { renderSQLPretty(w, stmts) }) return nil }, } // findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。 // 返回失败语句下标(0-based)、该 ERROR statement、是否命中。 func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) { for i, s := range stmts { if common.GetString(s, "sql_type") == "ERROR" { return i, s, true } } return 0, nil, false } // sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。 // // CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit // 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在 // 失败位置),note 提示用户别整批重跑(否则会重复写入)。 func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} { code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) stmtNo := errIdx + 1 // 1-based 给人看 note := "no statements were applied; fix the SQL and re-run." if errIdx > 0 { note = fmt.Sprintf( "statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.", errIdx, stmtNo) } return map[string]interface{}{ "results": stmts, "statement_index": errIdx, "error_code": code, "error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)), "rolled_back": false, "note": note, } } // parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。 // code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString),解析失败回退 0 / 原文。 func parseErrorSentinel(data string) (int, string) { if data == "" { return 0, "(unknown error)" } var e struct { Code interface{} `json:"code"` Message string `json:"message"` } if err := json.Unmarshal([]byte(data), &e); err != nil { return 0, data } code := 0 if cs := codeString(e.Code); cs != "" { if n, convErr := strconv.Atoi(cs); convErr == nil { code = n } } if e.Message == "" { return code, "(unknown error)" } return code, e.Message } // buildDBSQLParams 构造 sql 接口的 query:env + 强制 transactional=false(DBA 模式)。 // // CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。 func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ "env": rctx.Str("env"), "transactional": false, } } // buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。 func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ "sql": rctx.Str("sql"), } } // parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态: // // 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]` // —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。 // // 2. 字符串数组形态:`["[{...rows...}]", "", ...]` // —— 每条 statement 一个字符串:SELECT 是 rows JSON、DML/DDL 是空串; // 无 sql_type 元数据,CLI 端按内容形态推断(SELECT vs OK)。 // // 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`,pretty 路径原样打。 func parseSQLResult(resultStr string) []map[string]interface{} { if resultStr == "" { return nil } // 形态 1:结构化数组(每元素是 object) var structured []map[string]interface{} if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) { return structured } // 形态 2:字符串数组(每元素是 rows JSON 或 "") var legacy []string if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil { out := make([]map[string]interface{}, 0, len(legacy)) for _, rowsJSON := range legacy { out = append(out, normalizeLegacyStatement(rowsJSON)) } return out } return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}} } // isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。 // 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map),用 sql_type 存在性区分。 func isStructuredResult(stmts []map[string]interface{}) bool { if len(stmts) == 0 { return false } _, ok := stmts[0]["sql_type"] return ok } // normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。 // 推断规则:data 是非空 rows 数组 → sql_type=SELECT;空串 / 空数组 → sql_type=OK(DML/DDL 老 wire 不可分)。 func normalizeLegacyStatement(rowsJSON string) map[string]interface{} { stmt := map[string]interface{}{ "sql_type": "OK", "data": rowsJSON, } trimmed := strings.TrimSpace(rowsJSON) if trimmed == "" || trimmed == "null" { return stmt } var rows []interface{} if err := json.Unmarshal([]byte(trimmed), &rows); err != nil { // 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK return stmt } // 是 JSON 数组 → 视作 SELECT,含 record_count stmt["sql_type"] = "SELECT" stmt["record_count"] = float64(len(rows)) return stmt } // renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。 func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) { if len(stmts) == 0 { fmt.Fprintln(w, "(empty result)") return } if len(stmts) == 1 { renderSingleStatementPretty(w, stmts[0]) return } renderMultiStatementPretty(w, stmts) } // renderSingleStatementPretty 单条 statement pretty(无 Statement header)。 func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) { sqlType := common.GetString(s, "sql_type") switch { case sqlType == "SELECT": renderSelectRowsAsTable(w, common.GetString(s, "data")) case sqlType == "ERROR": // 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。 fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data"))) case isDMLType(sqlType): // 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE:✓ N row(s) fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"])) case sqlType == "OK": // legacy wire 下 DML / DDL 都映射成 OK(老 wire 不带 sql_type 元数据,无法区分动词 / 行数) fmt.Fprintln(w, "✓ ok") default: // 其余皆 DDL:真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。 fmt.Fprintln(w, "✓ DDL executed") } } // renderMultiStatementPretty 多条 statement pretty: // - 每条用 "Statement K: ✓ " / "Statement K: ✗ []" // - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格 // - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示 // (DBA 模式不回滚),失败本身由 Execute 升级成 typed error(exit 非 0) func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) { failedIdx := -1 successCount := 0 for i, s := range stmts { sqlType := common.GetString(s, "sql_type") idx := i + 1 switch { case sqlType == "ERROR": fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data"))) failedIdx = i case sqlType == "SELECT": rc := intOrZero(s["record_count"]) fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc)) renderSelectRowsAsTable(w, common.GetString(s, "data")) successCount++ case isDMLType(sqlType): fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"])) successCount++ case sqlType == "OK": fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx) successCount++ default: // DDL 族:CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ... fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx) successCount++ } if i < len(stmts)-1 { fmt.Fprintln(w) // statements 间留空行 } } fmt.Fprintln(w) if failedIdx >= 0 { // CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地, // 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。 if successCount > 0 { fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n", failedIdx+1, successCount, plural(int64(successCount))) } else { fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1) } } else { fmt.Fprintf(w, "✓ %d statements executed\n", successCount) } } // renderSelectRowsAsTable 把 SELECT 的 data(rows JSON 数组字符串)解析并渲染成对齐表格。 // 空结果输出 "(0 rows)"。 func renderSelectRowsAsTable(w io.Writer, dataJSON string) { if dataJSON == "" || dataJSON == "[]" { fmt.Fprintln(w, "(0 rows)") return } var rows []map[string]interface{} if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil { // 数据不符合预期 schema —— 原样打 fallback。 fmt.Fprintln(w, dataJSON) return } if len(rows) == 0 { fmt.Fprintln(w, "(0 rows)") return } headers := collectColumns(rows) cells := make([][]string, 0, len(rows)) for _, row := range rows { line := make([]string, 0, len(headers)) for _, h := range headers { line = append(line, cellString(row[h])) } cells = append(cells, line) } renderAlignedTable(w, headers, cells) } // collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定(map 无序), // 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序, // 这里按字典序保证可重现,agent / 测试可稳定 assert。 func collectColumns(rows []map[string]interface{}) []string { set := map[string]struct{}{} for _, r := range rows { for k := range r { set[k] = struct{}{} } } cols := make([]string, 0, len(set)) for k := range set { cols = append(cols, k) } sort.Strings(cols) return cols } // cellString 把任意 JSON value 转字符串显示(null → 空串;非字符串/数字 → JSON 编码)。 func cellString(v interface{}) string { switch x := v.(type) { case nil: return "" case string: return x case bool: if x { return "true" } return "false" case float64: // 整数值不输出小数(id=101 而不是 101.000000)。 if x == float64(int64(x)) { return fmt.Sprintf("%d", int64(x)) } return fmt.Sprintf("%g", x) } b, err := json.Marshal(v) if err != nil { return fmt.Sprintf("%v", v) } return string(b) } // dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) " 字符串。 // // 动词映射:INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。 // 未知 sql_type 默认 "affected"。 func dmlSummary(sqlType string, affectedRows interface{}) string { n := intOrZero(affectedRows) verb := dmlVerb(sqlType) return fmt.Sprintf("%d row%s %s", n, plural(n), verb) } // isDMLType 判断 sql_type 是否是行级 DML(带 affected_rows 语义)。 // 真机 boe wire:SELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余(CREATE_TABLE / // DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。 func isDMLType(sqlType string) bool { switch strings.ToUpper(sqlType) { case "INSERT", "UPDATE", "DELETE", "MERGE": return true } return false } func dmlVerb(sqlType string) string { switch strings.ToUpper(sqlType) { case "INSERT": return "inserted" case "UPDATE": return "updated" case "DELETE": return "deleted" case "MERGE": return "merged" } return "affected" } func plural(n int64) string { if n == 1 { return "" } return "s" } // errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON)解析出 "message [code]" 形态。 // 解析失败时回退到原文。 func errorSummary(data string) string { if data == "" { return "(unknown error)" } var e struct { Code interface{} `json:"code"` Message string `json:"message"` } if err := json.Unmarshal([]byte(data), &e); err != nil { return data } codeStr := codeString(e.Code) if codeStr != "" { return fmt.Sprintf("%s [%s]", e.Message, codeStr) } return e.Message } // codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。 func codeString(c interface{}) string { switch x := c.(type) { case nil: return "" case string: // "k_dl_1300015" → 抽 1300015;纯数字保持原样。 if strings.HasPrefix(x, "k_dl_") { return strings.TrimPrefix(x, "k_dl_") } return x case float64: return fmt.Sprintf("%d", int64(x)) } return "" } // intOrZero 把 JSON number 转 int64;nil / 类型不匹配返回 0。 func intOrZero(raw interface{}) int64 { if n, ok := numericAsFloat(raw); ok { return int64(n) } return 0 }