Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1960,16 +1960,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"has_more": false,
"record_id_list": []interface{}{"rec_1"},
"update": map[string]interface{}{"Status": "Done"},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
t.Fatalf("stdout=%s", got)
}
})
Expand All @@ -1981,20 +1979,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{"rec_1"},
},
"data": map[string]interface{}{},
},
}
reg.Register(updateStub)
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
t.Fatalf("stdout=%s", got)
}
body := string(updateStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
t.Fatalf("request body=%s", body)
}
})
Expand Down
11 changes: 7 additions & 4 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
"update_records maps each record ID to its field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
},
},
}
Expand Down Expand Up @@ -835,9 +836,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"Happy path field: update_records",
"update_records maps each record ID to its own field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"contains only optional ignored_fields",
"does not check whether record IDs exist",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
Expand Down
9 changes: 5 additions & 4 deletions shortcuts/base/record_batch_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@ import (
var BaseRecordBatchUpdate = common.Shortcut{
Service: "base",
Command: "+record-batch-update",
Description: "Batch update records",
Description: "Batch update records with record-specific fields",
Risk: "write",
Scopes: []string{"base:record:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
},
Tips: append([]string{
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
"Happy path field: update_records maps each record ID to its own field map.",
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Expand Down
2 changes: 1 addition & 1 deletion skills/lark-base/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ metadata:
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。

## 表单与视图细节
Expand Down
2 changes: 1 addition & 1 deletion skills/lark-base/references/lark-base-cell-value.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- `--json` 必须是 JSON 对象。
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`。
- `+record-batch-create`:`rows` 是 `CellValue[][]`,列顺序由 `fields` 决定。
- `+record-batch-update`:`patch` 是 `Map<FieldNameOrID, CellValue>`,同一份 `patch` 会应用到所有 `record_id_list`。
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`。
- 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
- 需要清空字段时优先传 `null`(字段允许清空时)。
Expand Down
20 changes: 11 additions & 9 deletions skills/lark-base/references/lark-base-record-batch-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。

批量更新记录(将同一份 `patch` 批量应用到一批 `record_id_list`)
通过 `update_records` 为每条记录提交字段值

## 推荐命令

```bash
lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> \
--json '{"record_id_list":["<record_id>"],"patch":{"状态":"完成"}}'
--json '{"update_records":{"<record_id_a>":{"状态":["完成"]},"<record_id_b>":{"分数":20}}}'

lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> --json @batch-update.json
```
Expand All @@ -29,23 +29,25 @@ lark-cli base +record-batch-update --base-token <base_token> --table-id <table_i

本节只说明 `+record-batch-update` 的外层 JSON 形状;CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。

对象形态:`{"record_id_list":[...],"patch":{...}}`。
对象形态:

```json
{"update_records":{"recA":{"状态":["完成"]},"recB":{"分数":20}}}
```

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `record_id_list` | `string[]` | 是 | 要更新的记录 ID 列表(单次最多 200 条) |
| `patch` | `Map<FieldNameOrID, CellValue>` | 是 | 字段更新对象;key 是字段名或字段 ID,value 是 `CellValue`;同一份 `patch` 会应用到 `record_id_list` 内所有记录 |
| `update_records` | `Map<RecordID, Map<FieldNameOrID, CellValue>>` | 是 | record ID 到字段更新对象的映射(单次最多 200 条) |

## 返回重点

返回 `record_id_list`、`update`,可选返回 `ignored_fields`;`update` 可能为空对象
成功响应只包含可选的 `ignored_fields`;没有忽略字段时 `data` 为空对象。请求不会预先校验 record ID 是否存在,因此需要确认实际写入结果时,应再用 `+record-get` 读回目标记录

## 坑点

- 这是“同值批量更新”:所有 `record_id_list` 都应用同一份 `patch`。
- `record_id_list` 最大 200 条,超过会被接口校验拒绝。
- 单次最多更新 200 条记录,超过会被接口校验拒绝。
- 命令不会自动做字段/行映射转换,传什么就发什么。
- 如果 `patch` 包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。
- 如果字段映射包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。

## 参考

Expand Down
39 changes: 39 additions & 0 deletions tests/cli_e2e/base/base_record_batch_update_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)

func TestBaseRecordBatchUpdatePerRecordDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)

result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "Done", clie2e.DryRunGet(out, "api.0.body.update_records.recA.Status.0").String(), out)
require.Equal(t, int64(20), clie2e.DryRunGet(out, "api.0.body.update_records.recB.Score").Int(), out)
}
111 changes: 111 additions & 0 deletions tests/cli_e2e/base/base_record_batch_update_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"encoding/json"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-batch-update-"+clie2e.GenerateSuffix())
tableID, _, _ := createTableWithRetry(
t,
parentT,
ctx,
baseToken,
"Batch Update "+clie2e.GenerateSuffix(),
`[{"name":"Name","type":"text"},{"name":"Status","type":"select","multiple":false,"options":[{"name":"Open"},{"name":"Done"}]},{"name":"Score","type":"number"}]`,
`{"name":"Main","type":"grid"}`,
)

createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-create",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"fields":["Name","Status","Score"],"rows":[["alpha","Open",10],["beta","Open",15]]}`,
},
DefaultAs: "bot",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)

firstRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.0").String()
secondRecordID := gjson.Get(createResult.Stdout, "data.record_id_list.1").String()
require.NotEmpty(t, firstRecordID, "stdout:\n%s", createResult.Stdout)
require.NotEmpty(t, secondRecordID, "stdout:\n%s", createResult.Stdout)

updateBody, err := json.Marshal(map[string]map[string]map[string]any{
"update_records": {
firstRecordID: {"Status": []string{"Done"}},
secondRecordID: {"Score": 20},
},
})
require.NoError(t, err)

updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", baseToken,
"--table-id", tableID,
"--json", string(updateBody),
},
DefaultAs: "bot",
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)

assertRecordFields := func(recordID, expectedStatus string, expectedScore int64) {
t.Helper()
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-get",
"--base-token", baseToken,
"--table-id", tableID,
"--record-id", recordID,
"--field-id", "Status",
"--field-id", "Score",
"--format", "json",
},
DefaultAs: "bot",
})
require.NoError(t, runErr)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
require.Equal(t, recordID, gjson.Get(result.Stdout, "data.record_id_list.0").String(), result.Stdout)
require.Equal(t, "Status", gjson.Get(result.Stdout, "data.fields.0").String(), result.Stdout)
require.Equal(t, "Score", gjson.Get(result.Stdout, "data.fields.1").String(), result.Stdout)
require.Equal(t, expectedStatus, gjson.Get(result.Stdout, "data.data.0.0.0").String(), result.Stdout)
require.Equal(t, expectedScore, gjson.Get(result.Stdout, "data.data.0.1").Int(), result.Stdout)
}

assertRecordFields(firstRecordID, "Done", 10)
assertRecordFields(secondRecordID, "Open", 20)

missingResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+record-batch-update",
"--base-token", baseToken,
"--table-id", tableID,
"--json", `{"update_records":{"recZZZZZZZZZZZ":{"Name":"missing-record"}}}`,
},
DefaultAs: "bot",
})
require.NoError(t, err)
missingResult.AssertExitCode(t, 0)
missingResult.AssertStdoutStatus(t, true)
}
14 changes: 8 additions & 6 deletions tests/cli_e2e/base/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

## Metrics
- Denominator: 78 leaf commands
- Covered: 19
- Coverage: 24.4%
- Covered: 22
- Coverage: 28.2%

## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.

## Command Table

Expand Down Expand Up @@ -54,10 +56,10 @@
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-update | shortcut | | none | form workflows not covered |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| | base +record-batch-create | shortcut | | none | record workflows not covered |
| | base +record-batch-update | shortcut | | none | record workflows not covered |
| | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.fields`; `--json.rows` | seeds heterogeneous live workflow records |
| | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
| ✕ | base +record-delete | shortcut | | none | record workflows not covered |
| | base +record-get | shortcut | | none | record workflows not covered |
| | base +record-get | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--record-id`; repeated `--field-id`; `--format json` | reads back select and number values after batch update |
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
Expand Down
Loading