From 3bda9e17de4c5475f92a3bb496191bbf87fa552d Mon Sep 17 00:00:00 2001 From: zgz2048 Date: Wed, 1 Jul 2026 16:08:55 +0800 Subject: [PATCH] fix: support field create json array input (#1661) --- shortcuts/base/base_dryrun_ops_test.go | 12 ++++ shortcuts/base/base_execute_test.go | 53 ++++++++++++++++-- shortcuts/base/base_shortcuts_test.go | 9 +++ shortcuts/base/field_ops.go | 55 +++++++++++++++---- .../references/lark-base-field-json.md | 1 + tests/cli_e2e/base/base_field_dryrun_test.go | 45 +++++++++++++++ tests/cli_e2e/base/coverage.md | 7 ++- 7 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 tests/cli_e2e/base/base_field_dryrun_test.go diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 17de7bdda..5a1646ce0 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -89,6 +89,18 @@ func TestDryRunFieldOps(t *testing.T) { ) assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields") + + arrayRT := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`, + }, + nil, + nil, + ) + assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`) + assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open") diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 79bd12ac4..24cbd6923 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -830,11 +830,6 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { shortcut common.Shortcut args []string }{ - { - name: "field create", - shortcut: BaseFieldCreate, - args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"}, - }, { name: "field update", shortcut: BaseFieldUpdate, @@ -1102,6 +1097,54 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("create array sequentially", func(t *testing.T) { + oldDelay := fieldCreateBatchDelay + fieldCreateBatchDelay = 0 + t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + firstStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"A"`) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + }, + } + secondStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"B"`) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"}, + }, + } + reg.Register(firstStub) + reg.Register(secondStub) + + err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["total"] != float64(2) { + t.Fatalf("unexpected output: %#v", data) + } + fields, _ := data["fields"].([]interface{}) + if len(fields) != 2 { + t.Fatalf("fields len=%d output=%#v", len(fields), data) + } + if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) { + t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody) + } + }) + t.Run("delete", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 4dd2769c4..9810cd708 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -1060,6 +1060,15 @@ func TestBaseFieldValidate(t *testing.T) { if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") { t.Fatalf("err=%v", err) } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil { + t.Fatalf("array create validate err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { t.Fatalf("err=%v", err) } diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 39d6f0d63..403be0964 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -6,10 +6,13 @@ package base import ( "context" "strings" + "time" "github.com/larksuite/cli/shortcuts/common" ) +var fieldCreateBatchDelay = time.Second + func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -33,12 +36,14 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body, _ := parseJSONObject(pc, runtime.Str("json"), "json") - return common.NewDryRunAPI(). - POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). - Body(body). + bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json")) + dr := common.NewDryRunAPI(). Set("base_token", runtime.Str("base-token")). Set("table_id", baseTableID(runtime)) + for _, body := range bodies { + dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body) + } + return dr } func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -95,11 +100,16 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin } func validateFieldCreate(runtime *common.RuntimeContext) error { - body, err := validateFieldJSON(runtime) + bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json")) if err != nil { return err } - return validateFormulaLookupGuideAck(runtime, "+field-create", body) + for _, body := range bodies { + if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil { + return err + } + } + return nil } func validateFieldUpdate(runtime *common.RuntimeContext) error { @@ -140,19 +150,40 @@ func executeFieldGet(runtime *common.RuntimeContext) error { } func executeFieldCreate(runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json")) if err != nil { return err } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) - if err != nil { - return err + fields := make([]interface{}, 0, len(bodies)) + for idx, body := range bodies { + if idx > 0 && fieldCreateBatchDelay > 0 { + time.Sleep(fieldCreateBatchDelay) + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) + if err != nil { + return err + } + fields = append(fields, data) } - runtime.Out(map[string]interface{}{"field": data, "created": true}, nil) + if len(fields) == 1 { + runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil) + return nil + } + runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil) return nil } +func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) { + bodies, err := parseObjectList(pc, raw, "json") + if err != nil { + return nil, err + } + if len(bodies) == 0 { + return nil, baseFlagErrorf("--json must contain at least one field JSON object") + } + return bodies, nil +} + func executeFieldUpdate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) baseToken := runtime.Str("base-token") diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index bfe77c3a3..26bf023f7 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -243,6 +243,7 @@ 默认值 / 约束: - `style.format` 默认 `yyyy/MM/dd` 可用格式:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`yyyy/MM/dd HH:mm Z`、`yyyy-MM-dd`、`yyyy-MM-dd HH:mm`、`yyyy-MM-dd HH:mm Z`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` +- `style.format` 只控制前端显示格式;当前可配置格式最多显示到分钟,底层时间值仍可保留秒级精度。 常用写法: diff --git a/tests/cli_e2e/base/base_field_dryrun_test.go b/tests/cli_e2e/base/base_field_dryrun_test.go new file mode 100644 index 000000000..9db5aff83 --- /dev/null +++ b/tests/cli_e2e/base/base_field_dryrun_test.go @@ -0,0 +1,45 @@ +// 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" + "github.com/tidwall/gjson" +) + +func TestBaseFieldCreateDryRunArrayCompat(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", "+field-create", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`, + "--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/fields", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "A", gjson.Get(out, "api.0.body.name").String(), out) + require.Equal(t, "text", gjson.Get(out, "api.0.body.type").String(), out) + + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", gjson.Get(out, "api.1.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), out) + require.Equal(t, "B", gjson.Get(out, "api.1.body.name").String(), out) + require.Equal(t, "text", gjson.Get(out, "api.1.body.type").String(), out) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index b3b2435b5..89472ba74 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -2,12 +2,13 @@ ## Metrics - Denominator: 78 leaf commands -- Covered: 18 -- Coverage: 23.1% +- Covered: 19 +- Coverage: 24.4% ## 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. - 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. @@ -38,7 +39,7 @@ | ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered | | ✕ | base +data-query | shortcut | | none | no data-query assertions yet | -| ✕ | base +field-create | shortcut | | none | field workflows not covered | +| ✓ | base +field-create | shortcut | base_field_dryrun_test.go::TestBaseFieldCreateDryRunArrayCompat | `--base-token`; `--table-id`; `--json`; dry-run only | request shape only | | ✕ | base +field-delete | shortcut | | none | field workflows not covered | | ✕ | base +field-get | shortcut | | none | field workflows not covered | | ✕ | base +field-list | shortcut | | none | field workflows not covered |