diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 22b0e07e..b3d59aa7 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -112,11 +112,43 @@ func TestDryRunRecordOps(t *testing.T) { nil, map[string]int{"max-version": 11, "page-size": 30}, ) - assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") - assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1") + getSingleRT := newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + map[string][]string{"record-id": {"rec_1"}}, + nil, + nil, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`) + assertDryRunContains(t, dryRunRecordDelete(ctx, getSingleRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_1"]`) + + getSingleFieldsRT := newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + map[string][]string{"record-id": {"rec_1"}, "field-id": {"Name", "Age"}}, + nil, + nil, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, getSingleFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_1"]`, `"select_fields":["Name","Age"]`) + + getBatchRT := newBaseTestRuntimeWithArrays( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + map[string][]string{"record-id": {"rec_2", "rec_1"}, "field-id": {"Name", "Age"}}, + nil, + nil, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_2","rec_1"]`, `"select_fields":["Name","Age"]`) + assertDryRunContains(t, dryRunRecordDelete(ctx, getBatchRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_2","rec_1"]`) + + getJSONRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"record_id_list":["rec_3"],"select_fields":["Status"]}`}, + nil, + nil, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_get", `"record_id_list":["rec_3"]`, `"select_fields":["Status"]`) + assertDryRunContains(t, dryRunRecordDelete(ctx, getJSONRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/batch_delete", `"record_id_list":["rec_3"]`) + uploadAttachmentRT := newBaseTestRuntime( map[string]string{ "base-token": "app_x", diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 3bf9e257..741b2f0e 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1054,42 +1054,322 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { t.Run("get", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"records": map[string]interface{}{ - "schema": []interface{}{"Name", "Age"}, - "record_ids": []interface{}{"rec_1"}, - "rows": []interface{}{[]interface{}{"Alice", 18}}, - }}, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_1"}, + "fields": []interface{}{"Name", "Age"}, + "data": []interface{}{[]interface{}{"Alice", 18}}, + }, }, - }) + } + reg.Register(batchStub) if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) { + got := stdout.String() + for _, want := range []string{ + "`_record_id` is metadata for record operations, not a table field.", + "- `_record_id`: rec_1", + "- `Name`: Alice", + "- `Age`: 18", + "Meta: count=1", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q:\n%s", want, got) + } + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_1"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("get json format", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_1"}, + "fields": []interface{}{"Name", "Age"}, + "data": []interface{}{[]interface{}{"Alice", 18}}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) { + t.Fatalf("stdout=%s", got) + } + if got := stdout.String(); !strings.Contains(got, `"rec_1"`) { t.Fatalf("stdout=%s", got) } }) - t.Run("get passthrough fallback", func(t *testing.T) { + t.Run("get with selected fields", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2", + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"}, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_1"}, + "fields": []interface{}{"Name", "Age"}, + "data": []interface{}{[]interface{}{"Alice", 18}}, + }, }, - }) - if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil { + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) { + t.Fatalf("stdout=%s", got) + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("get batch with repeated record-id flags", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_2", "rec_1"}, + "fields": []interface{}{"Name"}, + "data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) { + got := stdout.String() + for _, want := range []string{ + "| _record_id | Name |", + "| rec_2 | Bob |", + "| rec_1 | Alice |", + "Meta: count=2", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q:\n%s", want, got) + } + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("get batch json format", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_2", "rec_1"}, + "fields": []interface{}{"Name"}, + "data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get batch with json selector", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_3"}, + "fields": []interface{}{"Name"}, + "data": []interface{}{[]interface{}{"Carol"}}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) { t.Fatalf("stdout=%s", got) } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Status: 404, + Body: map[string]interface{}{"code": 404, "msg": "not found"}, + } + reg.Register(batchStub) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout) + if err == nil { + t.Fatalf("expected batch_get error") + } + if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) { + t.Fatalf("request body=%s", string(batchStub.CapturedBody)) + } + if stdout.Len() != 0 { + t.Fatalf("stdout=%s", stdout.String()) + } + }) + + t.Run("get single missing record renders not found markdown", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_missing"}, + "fields": []interface{}{"Name"}, + "data": []interface{}{[]interface{}{nil}}, + "has_more": false, + "record_not_found": []interface{}{"rec_missing"}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + for _, want := range []string{ + "Record not found.", + "- `_record_id`: rec_missing", + "Meta: count=1; has_more=false; record_not_found=1", + "Missing records: rec_missing", + } { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "- `Name`:") { + t.Fatalf("missing record output should not render business fields:\n%s", got) + } + }) + + t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Status: 404, + Body: map[string]interface{}{"code": 404, "msg": "not found"}, + } + reg.Register(batchStub) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout) + if err == nil { + t.Fatalf("expected batch_get error") + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) { + t.Fatalf("request body=%s", body) + } + if stdout.Len() != 0 { + t.Fatalf("stdout=%s", stdout.String()) + } + }) + + t.Run("get batch with json record ids and field flags", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_4"}, + "fields": []interface{}{"Status"}, + "data": []interface{}{[]interface{}{"Done"}}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"Done"`) { + t.Fatalf("stdout=%s", got) + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("get rejects duplicate record ids", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "duplicate record id") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get rejects duplicate field ids", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "duplicate field id") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get rejects mixed record-id and json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get rejects empty selection", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") { + t.Fatalf("err=%v", err) + } }) t.Run("create", func(t *testing.T) { @@ -1189,17 +1469,121 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { t.Run("delete", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "DELETE", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", - Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, - }) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_1"}, + }, + }, + } + reg.Register(batchStub) if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil { t.Fatalf("err=%v", err) } - if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) { + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) { + t.Fatalf("stdout=%s", got) + } + if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) { + t.Fatalf("request body=%s", string(batchStub.CapturedBody)) + } + }) + + t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete", + Status: 404, + Body: map[string]interface{}{"code": 404, "msg": "not found"}, + } + reg.Register(batchStub) + err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout) + if err == nil { + t.Fatalf("expected batch_delete error") + } + if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) { + t.Fatalf("request body=%s", string(batchStub.CapturedBody)) + } + if stdout.Len() != 0 { + t.Fatalf("stdout=%s", stdout.String()) + } + }) + + t.Run("delete batch with repeated record-id flags", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_2", "rec_1"}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) { t.Fatalf("stdout=%s", got) } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("delete batch with json selector", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + batchStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id_list": []interface{}{"rec_3"}, + }, + }, + } + reg.Register(batchStub) + if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) { + t.Fatalf("stdout=%s", got) + } + body := string(batchStub.CapturedBody) + if !strings.Contains(body, `"record_id_list":["rec_3"]`) { + t.Fatalf("request body=%s", body) + } + }) + + t.Run("delete requires yes for batch", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("delete rejects duplicate record ids", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "duplicate record id") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("delete rejects mixed record-id and json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } }) t.Run("upload attachment", func(t *testing.T) { diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 7ef9993e..76589d95 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -259,10 +259,15 @@ func TestBaseRecordReadHelpGuidesAgents(t *testing.T) { name: "record get", shortcut: BaseRecordGet, wantHelp: []string{ - "record ID", + "record ID (repeatable)", + "field ID or name to project; repeat to keep only needed columns", + "output format: markdown (default) | json", }, wantTips: []string{ "lark-cli base +record-get --base-token --table-id --record-id ", + "lark-cli base +record-get --base-token --table-id --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status", + "Default output is markdown", + "projection boundary", "record_id is already known", "lark-base record read SOP", }, @@ -355,8 +360,8 @@ func TestBaseRecordValidate(t *testing.T) { if BaseRecordSearch.Validate == nil { t.Fatalf("record search validate should reject invalid JSON before dry-run") } - if BaseRecordGet.Validate != nil { - t.Fatalf("record get validate should be nil") + if BaseRecordGet.Validate == nil { + t.Fatalf("record get validate should reject invalid record selection before dry-run") } if BaseRecordUpsert.Validate == nil { t.Fatalf("record upsert validate should reject invalid JSON before dry-run") diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index fc54b5d5..1038c33e 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -195,6 +195,62 @@ func TestRecordAndChunkHelpers(t *testing.T) { } } +func TestRecordSelectionHelpers(t *testing.T) { + recordIDs, err := normalizeRecordIDs([]string{" rec_1 ", "rec_2"}) + if err != nil || !reflect.DeepEqual(recordIDs, []string{"rec_1", "rec_2"}) { + t.Fatalf("recordIDs=%v err=%v", recordIDs, err) + } + if _, err := normalizeRecordIDs([]interface{}{}); err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") { + t.Fatalf("err=%v", err) + } + if _, err := normalizeRecordIDs([]interface{}{"rec_1", "rec_1"}); err == nil || !strings.Contains(err.Error(), "duplicate record id") { + t.Fatalf("err=%v", err) + } + if _, err := normalizeRecordIDs([]interface{}{" "}); err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("err=%v", err) + } + if _, err := normalizeRecordIDs([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") { + t.Fatalf("err=%v", err) + } + tooManyRecords := make([]string, maxRecordSelectionCount+1) + if _, err := normalizeRecordIDs(tooManyRecords); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") { + t.Fatalf("err=%v", err) + } + + fields, err := normalizeRecordGetSelectFields([]interface{}{" Name ", "fld_status"}) + if err != nil || !reflect.DeepEqual(fields, []string{"Name", "fld_status"}) { + t.Fatalf("fields=%v err=%v", fields, err) + } + if fields, err := normalizeRecordGetSelectFields(nil); err != nil || fields != nil { + t.Fatalf("fields=%v err=%v", fields, err) + } + if _, err := normalizeRecordGetSelectFields([]interface{}{"Name", "Name"}); err == nil || !strings.Contains(err.Error(), "duplicate field id") { + t.Fatalf("err=%v", err) + } + if _, err := normalizeRecordGetSelectFields([]interface{}{""}); err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("err=%v", err) + } + if _, err := normalizeRecordGetSelectFields([]interface{}{1}); err == nil || !strings.Contains(err.Error(), "must be a string") { + t.Fatalf("err=%v", err) + } + tooManyFields := make([]string, maxBatchGetSelectFieldCount+1) + if _, err := normalizeRecordGetSelectFields(tooManyFields); err == nil || !strings.Contains(err.Error(), "exceeds maximum limit") { + t.Fatalf("err=%v", err) + } + + fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}}) + if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) { + t.Fatalf("fields=%v err=%v", fields, err) + } + if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } + if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("err=%v", err) + } + +} + func TestResolveHelpers(t *testing.T) { fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}} tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}} diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go index a4e3bffb..158ddcb5 100644 --- a/shortcuts/base/record_delete.go +++ b/shortcuts/base/record_delete.go @@ -12,12 +12,20 @@ import ( var BaseRecordDelete = common.Shortcut{ Service: "base", Command: "+record-delete", - Description: "Delete a record by ID", + Description: "Delete one or more records by ID", Risk: "high-risk-write", Scopes: []string{"base:record:delete"}, AuthTypes: authTypes(), - Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)}, - DryRun: dryRunRecordDelete, + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, + {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateRecordSelection(runtime) + }, + DryRun: dryRunRecordDelete, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeRecordDelete(runtime) }, diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go index 2a8df612..f8d0b720 100644 --- a/shortcuts/base/record_get.go +++ b/shortcuts/base/record_get.go @@ -13,17 +13,29 @@ import ( var BaseRecordGet = common.Shortcut{ Service: "base", Command: "+record-get", - Description: "Get a record by ID", + Description: "Get one or more records by ID", Risk: "read", Scopes: []string{"base:record:read"}, AuthTypes: authTypes(), Flags: []common.Flag{ baseTokenFlag(true), tableRefFlag(true), - recordRefFlag(true), + {Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"}, + {Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"}, + {Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`}, + recordReadFormatFlag(), + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + return validateRecordSelection(runtime) }, Tips: []string{ "Example: lark-cli base +record-get --base-token --table-id --record-id ", + "Example with projection: lark-cli base +record-get --base-token --table-id --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status", + "Default output is markdown; pass --format json to get the raw JSON envelope.", + "Use --field-id as a projection boundary to avoid loading large cell values into context when they are not needed.", "Use +record-get when record_id is already known; otherwise use +record-search or +record-list.", "Agent hint: follow the lark-base record read SOP for record read routing.", }, diff --git a/shortcuts/base/record_markdown.go b/shortcuts/base/record_markdown.go index bb73964c..c379b716 100644 --- a/shortcuts/base/record_markdown.go +++ b/shortcuts/base/record_markdown.go @@ -24,6 +24,10 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error { } func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error { + return outputRecordMarkdownWithRenderer(runtime, data, renderRecordMarkdown) +} + +func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error { if runtime.JqExpr != "" { if !runtime.Changed("format") { runtime.Out(data, nil) @@ -31,7 +35,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf } return output.ErrValidation("--jq and --format markdown are mutually exclusive") } - rendered, err := renderRecordMarkdown(data) + rendered, err := renderer(data) if err != nil { fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err) runtime.Out(data, nil) @@ -48,6 +52,27 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf return nil } +func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error { + return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown) +} + +func renderRecordGetMarkdown(data map[string]interface{}) (string, error) { + fields := stringSliceValue(data["fields"]) + recordIDs := stringSliceValue(data["record_id_list"]) + rows, ok := data["data"].([]interface{}) + if len(fields) == 0 || !ok { + return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data") + } + if len(recordIDs) == 1 && len(rows) == 1 { + rowItems, _ := rows[0].([]interface{}) + if recordMarkedNotFound(data["record_not_found"], recordIDs[0]) { + return renderMissingSingleRecordMarkdown(recordIDs[0], data), nil + } + return renderSingleRecordMarkdown(recordIDs[0], fields, rowItems, data), nil + } + return renderRecordMarkdown(data) +} + func renderRecordMarkdown(data map[string]interface{}) (string, error) { fields := stringSliceValue(data["fields"]) recordIDs := stringSliceValue(data["record_id_list"]) @@ -91,9 +116,68 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) { b.WriteString(ignored) b.WriteByte('\n') } + if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" { + b.WriteString("Missing records: ") + b.WriteString(missing) + b.WriteByte('\n') + } return b.String(), nil } +func renderSingleRecordMarkdown(recordID string, fields []string, rowItems []interface{}, data map[string]interface{}) string { + var b strings.Builder + b.WriteString("`_record_id` is metadata for record operations, not a table field.\n\n") + b.WriteString("- `_record_id`: ") + b.WriteString(markdownInlineValue(recordID)) + b.WriteByte('\n') + for i, field := range fields { + b.WriteString("- `") + b.WriteString(field) + b.WriteString("`: ") + if i < len(rowItems) { + b.WriteString(markdownInlineValue(rowItems[i])) + } + b.WriteByte('\n') + } + meta := recordMarkdownMeta(data) + if len(meta) > 0 { + b.WriteString("\nMeta: ") + b.WriteString(strings.Join(meta, "; ")) + b.WriteByte('\n') + } + if ignored := ignoredFieldsMarkdown(data["ignored_fields"]); ignored != "" { + b.WriteString("Ignored fields: ") + b.WriteString(ignored) + b.WriteByte('\n') + } + if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" { + b.WriteString("Missing records: ") + b.WriteString(missing) + b.WriteByte('\n') + } + return b.String() +} + +func renderMissingSingleRecordMarkdown(recordID string, data map[string]interface{}) string { + var b strings.Builder + b.WriteString("Record not found.\n\n") + b.WriteString("- `_record_id`: ") + b.WriteString(markdownInlineValue(recordID)) + b.WriteByte('\n') + meta := recordMarkdownMeta(data) + if len(meta) > 0 { + b.WriteString("\nMeta: ") + b.WriteString(strings.Join(meta, "; ")) + b.WriteByte('\n') + } + if missing := recordNotFoundMarkdown(data["record_not_found"]); missing != "" { + b.WriteString("Missing records: ") + b.WriteString(missing) + b.WriteByte('\n') + } + return b.String() +} + func recordMarkdownMeta(data map[string]interface{}) []string { meta := []string{fmt.Sprintf("count=%d", ignoredFieldsCount(data["record_id_list"]))} if hasMore, ok := data["has_more"]; ok { @@ -109,6 +193,9 @@ func recordMarkdownMeta(data map[string]interface{}) []string { if ignoredCount := ignoredFieldsCount(data["ignored_fields"]); ignoredCount > 0 { meta = append(meta, fmt.Sprintf("ignored_fields=%d", ignoredCount)) } + if missingCount := ignoredFieldsCount(data["record_not_found"]); missingCount > 0 { + meta = append(meta, fmt.Sprintf("record_not_found=%d", missingCount)) + } return meta } @@ -138,6 +225,19 @@ func ignoredFieldsMarkdown(value interface{}) string { return strings.Join(items, ", ") } +func recordNotFoundMarkdown(value interface{}) string { + return strings.Join(markdownListItems(value), ", ") +} + +func recordMarkedNotFound(value interface{}, recordID string) bool { + for _, item := range markdownListItems(value) { + if item == recordID { + return true + } + } + return false +} + func markdownListItems(value interface{}) []string { switch v := value.(type) { case []interface{}: diff --git a/shortcuts/base/record_markdown_test.go b/shortcuts/base/record_markdown_test.go index 90eefcae..d361a620 100644 --- a/shortcuts/base/record_markdown_test.go +++ b/shortcuts/base/record_markdown_test.go @@ -83,6 +83,75 @@ func TestRenderRecordMarkdownEscapesTableCells(t *testing.T) { } } +func TestRenderRecordGetMarkdownSingleRecordUsesKVLayout(t *testing.T) { + got, err := renderRecordGetMarkdown(map[string]interface{}{ + "fields": []interface{}{"Name|Label", "Note"}, + "record_id_list": []interface{}{"rec_1"}, + "data": []interface{}{[]interface{}{"A|B", "line1\nline2"}}, + "has_more": false, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + for _, want := range []string{ + "- `_record_id`: rec_1", + "- `Name|Label`: A|B", + "- `Note`: line1\nline2", + "Meta: count=1; has_more=false", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func TestRenderRecordGetMarkdownSingleMissingRecordUsesNotFoundLayout(t *testing.T) { + got, err := renderRecordGetMarkdown(map[string]interface{}{ + "fields": []interface{}{"Name", "Note"}, + "record_id_list": []interface{}{"rec_missing"}, + "data": []interface{}{[]interface{}{nil, nil}}, + "record_not_found": []interface{}{"rec_missing"}, + "has_more": false, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + for _, want := range []string{ + "Record not found.", + "- `_record_id`: rec_missing", + "Meta: count=1; has_more=false; record_not_found=1", + "Missing records: rec_missing", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "- `Name`:") { + t.Fatalf("missing record layout should not render business fields:\n%s", got) + } +} + +func TestRenderRecordMarkdownIncludesMissingRecords(t *testing.T) { + got, err := renderRecordMarkdown(map[string]interface{}{ + "fields": []interface{}{"Name"}, + "record_id_list": []interface{}{"rec_1", "rec_missing"}, + "data": []interface{}{[]interface{}{"Alice"}, []interface{}{nil}}, + "record_not_found": []interface{}{"rec_missing"}, + "has_more": false, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + for _, want := range []string{ + "Meta: count=2; has_more=false; record_not_found=1", + "Missing records: rec_missing", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + func TestRenderRecordMarkdownTruncatesIgnoredFields(t *testing.T) { ignored := make([]interface{}, maxRecordMarkdownIgnoredFields+2) for i := range ignored { diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go index 4f534058..f1873c23 100644 --- a/shortcuts/base/record_ops.go +++ b/shortcuts/base/record_ops.go @@ -7,10 +7,194 @@ import ( "context" "net/url" "strconv" + "strings" "github.com/larksuite/cli/shortcuts/common" ) +const maxRecordSelectionCount = 200 +const maxBatchGetSelectFieldCount = 100 + +type recordSelection struct { + recordIDs []string + selectFields []string + fromJSON bool +} + +type stringListNormalizeOptions struct { + typeError string + emptyError string + itemName string + duplicateName string + limitName string + max int + allowNil bool + allowEmpty bool +} + +func validateRecordSelection(runtime *common.RuntimeContext) error { + _, err := resolveRecordSelection(runtime) + return err +} + +func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) { + recordIDs := runtime.StrArray("record-id") + fieldIDs := runtime.StrArray("field-id") + jsonRaw := strings.TrimSpace(runtime.Str("json")) + if len(recordIDs) > 0 && jsonRaw != "" { + return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive") + } + if jsonRaw != "" { + pc := newParseCtx(runtime) + body, err := parseJSONObject(pc, jsonRaw, "json") + if err != nil { + return recordSelection{}, err + } + recordIDListValue, ok := body["record_id_list"] + if !ok { + return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json")) + } + recordIDItems, ok := recordIDListValue.([]interface{}) + if !ok { + return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json")) + } + normalized, err := normalizeRecordIDs(recordIDItems) + if err != nil { + return recordSelection{}, err + } + selectFields, err := resolveRecordGetSelectFields(fieldIDs, body) + if err != nil { + return recordSelection{}, err + } + return recordSelection{ + recordIDs: normalized, + selectFields: selectFields, + fromJSON: true, + }, nil + } + normalized, err := normalizeRecordIDs(recordIDs) + if err != nil { + return recordSelection{}, err + } + selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil) + if err != nil { + return recordSelection{}, err + } + return recordSelection{ + recordIDs: normalized, + selectFields: selectFields, + }, nil +} + +func normalizeRecordIDs(values interface{}) ([]string, error) { + return normalizeStringList(values, stringListNormalizeOptions{ + typeError: "record selection must be a string array", + emptyError: `provide at least one --record-id, or use --json with "record_id_list"`, + itemName: "record selection item", + duplicateName: "record id", + limitName: "record selection", + max: maxRecordSelectionCount, + }) +} + +func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) { + fromFlags, err := normalizeRecordGetSelectFields(flagFields) + if err != nil { + return nil, err + } + if body == nil { + return fromFlags, nil + } + rawJSONFields, ok := body["select_fields"] + if !ok { + return fromFlags, nil + } + if len(fromFlags) > 0 { + return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`) + } + items, ok := rawJSONFields.([]interface{}) + if !ok { + return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json")) + } + if len(items) == 0 { + return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json")) + } + normalized, err := normalizeRecordGetSelectFields(items) + if err != nil { + return nil, err + } + return normalized, nil +} + +func normalizeRecordGetSelectFields(values interface{}) ([]string, error) { + return normalizeStringList(values, stringListNormalizeOptions{ + typeError: "field selection must be a string array", + itemName: "field selection item", + duplicateName: "field id", + limitName: "field selection", + max: maxBatchGetSelectFieldCount, + allowNil: true, + allowEmpty: true, + }) +} + +func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([]string, error) { + var rawItems []interface{} + switch typed := values.(type) { + case nil: + if opts.allowNil { + return nil, nil + } + return nil, common.FlagErrorf(opts.typeError) + case []interface{}: + rawItems = typed + case []string: + rawItems = make([]interface{}, 0, len(typed)) + for _, item := range typed { + rawItems = append(rawItems, item) + } + default: + return nil, common.FlagErrorf(opts.typeError) + } + if len(rawItems) == 0 { + if opts.allowEmpty { + return nil, nil + } + return nil, common.FlagErrorf(opts.emptyError) + } + if opts.max > 0 && len(rawItems) > opts.max { + return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems)) + } + seen := make(map[string]int, len(rawItems)) + result := make([]string, 0, len(rawItems)) + for index, value := range rawItems { + item, ok := value.(string) + if !ok { + return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1) + } + item = strings.TrimSpace(item) + if item == "" { + return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1) + } + if first, exists := seen[item]; exists { + return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1) + } + seen[item] = index + 1 + result = append(result, item) + } + return result, nil +} + +func recordGetBatchBody(selection recordSelection) map[string]interface{} { + body := map[string]interface{}{ + "record_id_list": selection.recordIDs, + } + if len(selection.selectFields) > 0 { + body["select_fields"] = selection.selectFields + } + return body +} + func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -34,11 +218,15 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common } func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + selection, err := resolveRecordSelection(runtime) + if err != nil { + return common.NewDryRunAPI() + } return common.NewDryRunAPI(). - GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_get"). + Body(recordGetBatchBody(selection)). Set("base_token", runtime.Str("base-token")). - Set("table_id", baseTableID(runtime)). - Set("record_id", runtime.Str("record-id")) + Set("table_id", baseTableID(runtime)) } func dryRunRecordSearch(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -90,11 +278,15 @@ func dryRunRecordBatchUpdate(_ context.Context, runtime *common.RuntimeContext) } func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + selection, err := resolveRecordSelection(runtime) + if err != nil { + return common.NewDryRunAPI() + } return common.NewDryRunAPI(). - DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete"). + Body(map[string]interface{}{"record_id_list": selection.recordIDs}). Set("base_token", runtime.Str("base-token")). - Set("table_id", baseTableID(runtime)). - Set("record_id", runtime.Str("record-id")) + Set("table_id", baseTableID(runtime)) } func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -201,10 +393,21 @@ func executeRecordList(runtime *common.RuntimeContext) error { } func executeRecordGet(runtime *common.RuntimeContext) error { - data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + if err := validateRecordReadFormat(runtime); err != nil { + return err + } + selection, err := resolveRecordSelection(runtime) if err != nil { return err } + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_get"), nil, recordGetBatchBody(selection)) + data, err := handleBaseAPIResult(result, err, "batch get records") + if err != nil { + return err + } + if runtime.Str("format") == "markdown" { + return outputRecordGetMarkdown(runtime, data) + } runtime.Out(data, nil) return nil } @@ -281,10 +484,17 @@ func executeRecordBatchUpdate(runtime *common.RuntimeContext) error { } func executeRecordDelete(runtime *common.RuntimeContext) error { - _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + selection, err := resolveRecordSelection(runtime) if err != nil { return err } - runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil) + result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", "batch_delete"), nil, map[string]interface{}{ + "record_id_list": selection.recordIDs, + }) + data, err := handleBaseAPIResult(result, err, "batch delete records") + if err != nil { + return err + } + runtime.Out(data, nil) return nil } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 69c597e9..5b477946 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "string_array" + Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index a9e54dd4..97537c48 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -104,11 +104,12 @@ metadata: | 命令 | 用途 / 何时使用 | 必读 reference | 路由提醒 | |------|------------------|----------------|----------| -| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或获取单条记录详情 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query` | +| `+record-search / +record-list / +record-get` | 按关键词检索记录、读取记录明细 / 分页导出,或按 ID 获取一条或多条记录 | [`lark-base-record-read-sop.md`](references/lark-base-record-read-sop.md) | 记录读取统一先读 read SOP guide:已知 `record_id` 用 `+record-get`;明确关键词用 `+record-search`;普通明细用 `+record-list`;明确筛选 / 排序 / Top N 用临时视图投影后 `+record-list --view-id`;统计聚合才分流到 `+data-query`;`+record-get` 支持重复 `--record-id` 或 `--json` 读取多条记录 | | `+record-upsert / +record-batch-create / +record-batch-update` | 创建、更新或批量写入记录 | [`lark-base-record-upsert.md`](references/lark-base-record-upsert.md)、[`lark-base-record-batch-create.md`](references/lark-base-record-batch-create.md)、[`lark-base-record-batch-update.md`](references/lark-base-record-batch-update.md)、[`lark-base-cell-value.md`](references/lark-base-cell-value.md) | 写前先 `+field-list`;只写存储字段;`+record-batch-update` 为同值更新(同一 patch 应用到多条记录);批量单次不超过 `200` 条;附件不要走这里 | | `+record-upload-attachment` | 给已有记录上传附件 | [`lark-base-record-upload-attachment.md`](references/lark-base-record-upload-attachment.md) | 附件上传专用链路,不要用 `+record-upsert` / `+record-batch-*` 伪造附件值 | | `lark-cli docs +media-download` | 下载 Base 附件文件到本地 | [`../lark-doc/references/lark-doc-media-download.md`](../lark-doc/references/lark-doc-media-download.md) | Base 附件的 `file_token` 从 `+record-get` 返回的附件字段数组里取;**不要用 `lark-cli drive +download`**(对 Base 附件返回 403) | -| `+record-delete / +record-history-list` | 删除记录,或查询某条记录的变更历史 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md)、[`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 删除时用户已明确目标可直接执行并带 `--yes`;历史查询按 `table-id + record-id`,不支持整表扫描;`+record-history-list` 只能串行执行 | +| `+record-delete` | 删除一条或多条记录 | [`lark-base-record-delete.md`](references/lark-base-record-delete.md) | 删除多条时重复传 `--record-id` 指定多个记录;用户已明确目标可直接执行并带 `--yes` | +| `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 | | `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 | #### 2.3.4 View 子模块 diff --git a/skills/lark-base/references/lark-base-record-delete.md b/skills/lark-base/references/lark-base-record-delete.md index 203e30d6..26a2c60a 100644 --- a/skills/lark-base/references/lark-base-record-delete.md +++ b/skills/lark-base/references/lark-base-record-delete.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -删除一条记录。 +删除一条或多条记录。 ## 推荐命令 @@ -14,25 +14,36 @@ lark-cli base +record-delete \ --yes ``` +```bash +lark-cli base +record-delete \ + --base-token app_xxx \ + --table-id tbl_xxx \ + --record-id rec_001 \ + --record-id rec_002 \ + --yes +``` + ## 参数 | 参数 | 必填 | 说明 | |------|------|------| | `--base-token ` | 是 | Base Token | | `--table-id ` | 是 | 表 ID 或表名 | -| `--record-id ` | 是 | 记录 ID | +| `--record-id ` | 否 | 与 `--json` 二选一;记录 ID,可重复使用;这是主推荐用法 | +| `--json ` | 否 | 与 `--record-id` 二选一;脚本/代理场景可传 `{"record_id_list":["rec_xxx"]}` | ## API 入参详情 **HTTP 方法和路径:** -``` -DELETE /open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id +```http +POST /open-apis/base/v3/bases/:base_token/tables/:table_id/records/batch_delete ``` ## 返回重点 -- 返回 `deleted: true` 和 `record_id`。 +- CLI 内部统一通过 `batch_delete` 删除记录;单个和多个 `--record-id` 使用相同的批量删除输出形态。 +- 成功时直接返回接口 `data` 字段内容,通常包含 `record_id_list`。 ## 工作流 diff --git a/skills/lark-base/references/lark-base-record.md b/skills/lark-base/references/lark-base-record.md index 21059f47..79af2918 100644 --- a/skills/lark-base/references/lark-base-record.md +++ b/skills/lark-base/references/lark-base-record.md @@ -14,7 +14,7 @@ record 相关命令索引。 | [lark-base-record-batch-update.md](lark-base-record-batch-update.md) | `+record-batch-update` | 批量更新记录 | | [lark-base-record-upload-attachment.md](lark-base-record-upload-attachment.md) | `+record-upload-attachment` | 上传本地文件到附件字段并更新记录 | | [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md) | `lark-cli docs +media-download` | 下载 Base 附件到本地(附件的 `file_token` 来自 `+record-get` 的附件字段) | -| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除记录 | +| [lark-base-record-delete.md](lark-base-record-delete.md) | `+record-delete` | 删除一条或多条记录 | | [lark-base-record-share-link-create.md](lark-base-record-share-link-create.md) | `+record-share-link-create` | 生成记录分享链接(支持单条或批量,最多 100 条)| ## 说明 @@ -23,6 +23,7 @@ record 相关命令索引。 - 聚合页只保留目录职责;写入、删除、历史等命令的详细说明请进入对应单命令文档。 - 所有 `+xxx-list` 调用都必须串行执行;若要批量跑多个 list 请求,只能串行执行。 - `+record-list` 支持重复传参 `--field-id` 做字段筛选。 +- `+record-get` 支持重复 `--record-id` 或 `--json '{"record_id_list":[...]}'` 批量读取;也支持重复传参 `--field-id` 裁剪返回字段,避免返回全字段。 - 写记录 JSON 前优先阅读 [lark-base-cell-value.md](lark-base-cell-value.md)。 - 本地文件写入附件字段时,必须使用 `+record-upload-attachment`。 - 从附件字段下载文件时,用 `lark-cli docs +media-download --token --output `,用法见 [`../../lark-doc/references/lark-doc-media-download.md`](../../lark-doc/references/lark-doc-media-download.md)。