Files
larksuite__cli/shortcuts/base/record_query.go
zgz2048 8b824b03ae feat(base): add typed NDJSON workflows for professional data analysis (#2196)
* feat(base): add typed NDJSON data analysis workflow

* docs(base): simplify cloud pagination guidance

* docs(base): clarify Link relation IDs

* feat(base): query NDJSON records with jq

* docs(base): route analysis through built-in jq

* docs: clarify per-table local analysis limit

* docs: simplify base analysis guidance

* docs: centralize base analysis routing

* docs(base): refine cell value and datetime guidance

* test(base): align ignored field fixtures

* docs(base): add semantic analysis routing

* feat(base): improve NDJSON analysis workflow

* docs(base): centralize filter predicate examples

* feat(base): clarify record get export scope

* feat(base): improve ndjson analysis workflow

* docs(base): refine local analysis guidance

* feat(base): resolve record search limit by format

* refactor(base): keep ndjson jq handling local
2026-08-11 20:27:37 +08:00

365 lines
9.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"fmt"
"math"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/base/recordexport"
"github.com/larksuite/cli/shortcuts/common"
)
const (
recordFilterJSONFlag = "filter-json"
recordSortJSONFlag = "sort-json"
recordSortMaxCount = 10
)
func recordFilterFlag() common.Flag {
return common.Flag{
Name: recordFilterJSONFlag,
Desc: `filter JSON object or @file, same shape as view filter JSON; overrides --view-id view filters`,
Input: []string{common.File},
}
}
func recordSortFlag() common.Flag {
return common.Flag{
Name: recordSortJSONFlag,
Desc: `sort JSON array or @file, e.g. [{"field":"Updated","desc":true}]; also accepts {"sort_config":[...]}; order is priority; max 10`,
Input: []string{common.File},
}
}
func validateRecordQueryOptions(runtime *common.RuntimeContext) error {
if _, err := parseRecordFilterFlag(runtime); err != nil {
return err
}
_, err := parseRecordSortFlag(runtime)
return err
}
func parseRecordFilterFlag(runtime *common.RuntimeContext) (interface{}, error) {
filterRaw := strings.TrimSpace(runtime.Str(recordFilterJSONFlag))
if filterRaw == "" {
return nil, nil
}
pc := newParseCtx(runtime)
return parseJSONObject(pc, filterRaw, recordFilterJSONFlag)
}
func parseRecordSortFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
sortRaw := strings.TrimSpace(runtime.Str(recordSortJSONFlag))
if sortRaw == "" {
return nil, nil
}
pc := newParseCtx(runtime)
value, err := parseJSONValue(pc, sortRaw, recordSortJSONFlag)
if err != nil {
return nil, err
}
return normalizeRecordSortValue(value, "--"+recordSortJSONFlag)
}
func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, error) {
var sortConfig []interface{}
if parsed, ok := value.([]interface{}); ok {
sortConfig = parsed
} else if obj, ok := value.(map[string]interface{}); ok {
rawSortConfig, ok := obj["sort_config"]
if !ok {
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
parsed, ok := rawSortConfig.([]interface{})
if !ok {
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label)
}
sortConfig = parsed
} else {
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label)
}
if len(sortConfig) > recordSortMaxCount {
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
}
return sortConfig, nil
}
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err)
}
return string(data), nil
}
func applyRecordQueryToParams(runtime *common.RuntimeContext, params map[string]interface{}) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
if err != nil {
return err
}
params["filter"] = filterJSON
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
if err != nil {
return err
}
params["sort"] = sortJSON
}
return nil
}
func applyRecordQueryToURLValues(runtime *common.RuntimeContext, params url.Values) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
filterJSON, err := marshalRecordQueryFlag(recordFilterJSONFlag, filter)
if err != nil {
return err
}
params["filter"] = []string{filterJSON}
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
sortJSON, err := marshalRecordQueryFlag(recordSortJSONFlag, sortConfig)
if err != nil {
return err
}
params["sort"] = []string{sortJSON}
}
return nil
}
func applyRecordQueryToBody(runtime *common.RuntimeContext, body map[string]interface{}) error {
filter, err := parseRecordFilterFlag(runtime)
if err != nil {
return err
}
if filter != nil {
body["filter"] = filter
}
sortConfig, err := parseRecordSortFlag(runtime)
if err != nil {
return err
}
if len(sortConfig) > 0 {
body["sort"] = sortConfig
}
return nil
}
func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := map[string]interface{}{}
if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" {
body["keyword"] = keyword
}
searchFields := runtime.StrArray("search-field")
if len(searchFields) > 0 {
body["search_fields"] = searchFields
}
selectFields, err := recordSearchProjectionFields(runtime)
if err != nil {
return nil, err
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
if viewID := runtime.Str("view-id"); viewID != "" {
body["view_id"] = viewID
}
offset := runtime.Int("offset")
if offset < 0 {
offset = 0
}
body["offset"] = offset
body["limit"] = runtime.Int("limit")
return body, applyRecordQueryToBody(runtime, body)
}
func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
if err != nil {
return nil, err
}
if err := normalizeRecordSearchJSONBody(body); err != nil {
return nil, err
}
if _, exists := body["limit"]; !exists && runtime.Str("format") == recordexport.FormatNDJSON {
body["limit"] = maxNDJSONRecordReadLimit
}
return body, applyRecordQueryToBody(runtime, body)
}
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
if rawSelectFields, ok := body["select_fields"]; ok {
if rawSelectFields == nil {
delete(body, "select_fields")
} else {
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
if err != nil {
return withValidationParam(err, "--json")
}
if len(selectFields) > 0 {
body["select_fields"] = selectFields
}
}
}
if rawSort, ok := body["sort"]; ok {
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
body["sort"] = sortConfig
} else {
return err
}
}
return nil
}
func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
if err := validateRecordExportFlags(runtime); err != nil {
return err
}
jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" {
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
allParams := append([]string{"--json"}, exclusiveParams...)
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
for _, param := range allParams {
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--json is mutually exclusive with %s",
strings.Join(exclusiveParams, " and "),
).
WithParam("--json").
WithParams(invalidParams...).
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
}
body, err := recordSearchJSONBody(runtime)
if err != nil {
return err
}
_, limit, err := recordSearchPagination(body)
if err != nil {
return withValidationParam(err, "--json")
}
maximum := maxInlineRecordReadLimit
if runtime.Str("format") == "ndjson" {
maximum = maxNDJSONRecordReadLimit
}
if limit > maximum {
return withValidationParam(baseFlagErrorf("limit must be between 1 and %d; got %d", maximum, limit), "--json")
}
return nil
}
if strings.TrimSpace(runtime.Str("keyword")) == "" {
return baseFlagErrorf("--keyword is required unless --json is used")
}
if len(runtime.StrArray("search-field")) == 0 {
return baseFlagErrorf("--search-field is required unless --json is used")
}
if err := validateRecordReadLimit(runtime, 10); err != nil {
return err
}
if _, err := recordSearchProjectionFields(runtime); err != nil {
return err
}
return validateRecordQueryOptions(runtime)
}
func recordSearchPagination(body map[string]any) (int, int, error) {
offset := 0
if raw, exists := body["offset"]; exists {
value, err := recordSearchInteger(raw, "offset")
if err != nil {
return 0, 0, err
}
if value < 0 {
return 0, 0, baseFlagErrorf("offset must be greater than or equal to 0; got %d", value)
}
offset = value
}
limit := 10
if raw, exists := body["limit"]; exists {
value, err := recordSearchInteger(raw, "limit")
if err != nil {
return 0, 0, err
}
if value < 1 {
return 0, 0, baseFlagErrorf("limit must be greater than or equal to 1; got %d", value)
}
limit = value
}
return offset, limit, nil
}
func recordSearchInteger(raw any, name string) (int, error) {
switch value := raw.(type) {
case int:
return value, nil
case int64:
return int(value), nil
case json.Number:
parsed, err := value.Int64()
if err != nil {
return 0, baseFlagErrorf("%s must be an integer; got %v", name, raw)
}
return int(parsed), nil
case float64:
if math.Trunc(value) != value {
return 0, baseFlagErrorf("%s must be an integer; got %v", name, raw)
}
return int(value), nil
default:
return 0, baseFlagErrorf("%s must be an integer; got %T", name, raw)
}
}
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
names := []string{
"keyword",
"search-field",
"field-id",
"fields",
"field-names",
"view-id",
"offset",
"limit",
}
params := make([]string, 0, len(names))
for _, name := range names {
if runtime.Changed(name) {
params = append(params, "--"+name)
}
}
return params
}
func formatRecordQueryPriorityTip() string {
return fmt.Sprintf("Query priority: --%s overrides --view-id's view filter JSON; --%s overrides --view-id's view sort config.", recordFilterJSONFlag, recordSortJSONFlag)
}