feat(base): add record field filters (#327)

* feat(base): add record field filters

* fix(base): align record field filter flags with OpenAPI params

* fix: scope record dry-run field filters and align docs

* docs(base): clarify record-list field_scope priority

* refactor(base): remove field-id from record-get

---------

Co-authored-by: zgz2048 <zhonggangzhi.tim@bytedance.com>
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
This commit is contained in:
kongenpei
2026-04-10 16:30:54 +08:00
committed by GitHub
parent 353c473e52
commit cd7a2363e5
10 changed files with 136 additions and 22 deletions

View File

@@ -63,12 +63,21 @@ func TestDryRunFieldOps(t *testing.T) {
func TestDryRunRecordOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(
listRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"},
map[string][]string{"field-id": {"Name", "Age"}},
nil,
map[string]int{"offset": -3, "limit": 500},
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1")
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
commaFieldRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-id": {"A,B", "C"}},
nil,
map[string]int{"limit": 1},
)
assertDryRunContains(t, dryRunRecordList(ctx, commaFieldRT), "limit=1", "offset=0", "field_id=A%2CB", "field_id=C")
upsertCreateRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},

View File

@@ -471,6 +471,52 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list with fields and view", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0&view_id=vew_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_fields"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list with comma field", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=A%2CB&field_id=C&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"A,B", "C"},
"record_id_list": []interface{}{"rec_json_fields"},
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list new shape", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -494,6 +540,22 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
})
t.Run("get", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -18,10 +18,17 @@ import (
)
func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArrays(stringFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
@@ -32,6 +39,11 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
for name, value := range stringFlags {
_ = cmd.Flags().Set(name, value)
}
for name, values := range stringArrayFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -236,10 +248,10 @@ func TestBaseTableValidate(t *testing.T) {
func TestBaseRecordValidate(t *testing.T) {
ctx := context.Background()
if BaseRecordList.Validate != nil {
t.Fatalf("record list validate should be nil after removing --fields")
t.Fatalf("record list validate should be nil for repeatable --field-id")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil after removing --fields")
t.Fatalf("record get validate should be nil")
}
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil)); err != nil {
t.Fatalf("upsert validate err=%v", err)

View File

@@ -379,7 +379,18 @@ func baseV3Path(parts ...string) string {
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
for k, v := range params {
queryParams.Set(k, fmt.Sprintf("%v", v))
switch val := v.(type) {
case []string:
for _, item := range val {
queryParams.Add(k, item)
}
case []interface{}:
for _, item := range val {
queryParams.Add(k, fmt.Sprintf("%v", item))
}
default:
queryParams.Set(k, fmt.Sprintf("%v", v))
}
}
req := &larkcore.ApiReq{
HttpMethod: strings.ToUpper(method),

View File

@@ -19,6 +19,7 @@ var BaseRecordList = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "field-id", Type: "string_array", Desc: "field ID or field name to include (repeatable)"},
{Name: "view-id", Desc: "view ID"},
{Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"},
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size"},

View File

@@ -5,6 +5,8 @@ package base
import (
"context"
"net/url"
"strconv"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -15,13 +17,18 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
offset = 0
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
params := map[string]interface{}{"offset": offset, "limit": limit}
if viewID := runtime.Str("view-id"); viewID != "" {
params["view_id"] = viewID
params := url.Values{}
params.Set("offset", strconv.Itoa(offset))
params.Set("limit", strconv.Itoa(limit))
for _, field := range recordListFields(runtime) {
params.Add("field_id", field)
}
if viewID := runtime.Str("view-id"); viewID != "" {
params.Set("view_id", viewID)
}
path := "/open-apis/base/v3/bases/:base_token/tables/:table_id/records?" + params.Encode()
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records").
Params(params).
GET(path).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime))
}
@@ -79,6 +86,10 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
return nil
}
func recordListFields(runtime *common.RuntimeContext) []string {
return runtime.StrArray("field-id")
}
func executeRecordList(runtime *common.RuntimeContext) error {
offset := runtime.Int("offset")
if offset < 0 {
@@ -86,6 +97,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
}
limit := common.ParseIntBounded(runtime, "limit", 1, 200)
params := map[string]interface{}{"offset": offset, "limit": limit}
fields := recordListFields(runtime)
if len(fields) > 0 {
params["field_id"] = fields
}
if viewID := runtime.Str("view-id"); viewID != "" {
params["view_id"] = viewID
}

View File

@@ -164,7 +164,8 @@ metadata:
- **Base token 口径统一**:统一使用 `--base-token`
- **`+xxx-list` 调用纪律**`+table-list / +field-list / +record-list / +view-list / +record-history-list / +role-list / +dashboard-list / +dashboard-block-list / +workflow-list` 禁止并发调用;批量执行时只能串行
- **`+record-list` 分页规则**`--limit` 最大 `200`。先拉首批并检查返回 `has_more`;仅当 `has_more=true` 且用户明确需要更多数据(如“全部导出/全量明细/继续下一页”)时再继续翻页。用户只要样例或前 N 条时,不要继续拉全量
- **`+record-list` 分页与 limit**`--limit` 最大 `200`。先拉首批并检查 `has_more`;仅当 `has_more=true` 且用户明确需要更多数据(如“全部导出/全量明细/继续下一页”)时再`offset` 递增翻页,禁止单次传超过 `200`
- **记录读取字段筛选**`+record-list` 支持重复传参 --field-id 做字段筛选
- **字段可写性先判断**:存储字段才可写;公式 / lookup / 系统字段默认只读,写记录时应跳过
- **公式能力要主动想到**:用户说“算一下”“生成标签”“判断是否异常”“跨表汇总”“按日期差预警”时,要先判断是否应该建公式字段,而不是只返回手工分析方案
- **lookup 不是默认首选**lookup 只在用户明确要求或确实更适合固定查找模型时使用;常规计算、跨表聚合和条件判断优先 formula

View File

@@ -11,12 +11,6 @@ lark-cli base +record-get \
--base-token app_xxx \
--table-id tbl_xxx \
--record-id rec_xxx
lark-cli base +record-get \
--base-token app_xxx \
--table-id tbl_xxx \
--record-id rec_xxx \
--fields 项目名称,状态
```
## 参数
@@ -26,7 +20,6 @@ lark-cli base +record-get \
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--record-id <id>` | 是 | 记录 ID |
| `--fields <csv_or_json>` | 否 | 字段名 CSV或 JSON 字符串数组 |
## API 入参详情
@@ -38,8 +31,7 @@ GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id
## 返回重点
- 返回 `record``raw`
- `record` 是裁剪后的单条结果;`raw` 保留接口完整响应。
- 成功时直接返回接口 `data` 字段内容
## 参考

View File

@@ -2,13 +2,19 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
分页列出一张表里的记录;可按视图过滤。
分页列出一张表里的记录;可按视图过滤,也可按字段裁剪返回列
## 返回关键字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `has_more` | boolean | 是否还有下一页数据;`true` 表示可继续翻页,`false` 表示已到末页 |
| `query_context.record_scope` | string | 记录范围:`all_records`(全表)或 `view_filtered_records`(按视图过滤) |
| `query_context.field_scope` | string | 字段范围:`selected_fields`(显式传 `--field-id`/ `view_visible_fields`(未传 `--field-id` 且按视图可见字段)/ `all_fields`(未传 `--field-id` 且无视图限制) |
## 字段返回优先级
- `query_context.field_scope` 的优先级为:`selected_fields`explicit `--field-id` > `view_visible_fields`view visible fields > `all_fields`table all fields
## 按需翻页规则
@@ -33,6 +39,8 @@ lark-cli base +record-list \
--base-token app_xxx \
--table-id tbl_xxx \
--view-id viw_xxx \
--field-id fld_status \
--field-id 项目名称 \
--offset 0 \
--limit 50
```
@@ -44,6 +52,7 @@ lark-cli base +record-list \
| `--base-token <token>` | 是 | Base Token |
| `--table-id <id_or_name>` | 是 | 表 ID 或表名 |
| `--view-id <id>` | 否 | 视图 ID传入后只读该视图结果 |
| `--field-id <id_or_name>` | 否 | 字段 ID 或字段名;可重复传入多个 `--field-id` 裁剪返回列 |
| `--offset <n>` | 否 | 分页偏移,默认 `0` |
| `--limit <n>` | 否 | 分页大小,默认 `100`,范围 `1-200`(最大 `200`,超过会报错) |
@@ -55,7 +64,7 @@ lark-cli base +record-list \
GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
```
- 查询参数会附带 `view_id / offset / limit`
- 查询参数会附带 `view_id / field_id(repeatable) / offset / limit`
## 坑点
@@ -63,6 +72,7 @@ GET /open-apis/base/v3/bases/:base_token/tables/:table_id/records
- ⚠️ `+record-list` 禁止并发调用;批量拉多个视图或多张表时必须串行。
- ⚠️ `--limit` 最大 `200`,不要传超过 `200` 的值。
- ⚠️ 分页时优先根据返回的 `has_more` 判断是否继续请求,不要盲目预拉全量数据。
- ⚠️ `--field-id` 接受字段 ID 或字段名。
- ⚠️ 复杂筛选优先落到视图里,再用 `--view-id` 读取。
## 参考

View File

@@ -18,5 +18,6 @@ record 相关命令索引。
- 聚合页只保留目录职责;每个命令的详细说明请进入对应单命令文档。
- 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。
- `+record-list` 支持重复传参 `--field-id` 做字段筛选。
- 写记录 JSON 前优先阅读 [lark-base-shortcut-record-value.md](lark-base-shortcut-record-value.md)。
- 本地文件写入附件字段时,必须使用 `+record-upload-attachment`