Skip to content

Commit 1c99234

Browse files
committed
feat(compositions): add the sorting strategy command include interactive mode
1 parent b887a30 commit 1c99234

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

pkg/cmd/compositions/compositions.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/algolia/cli/pkg/cmd/compositions/list"
99
"github.com/algolia/cli/pkg/cmd/compositions/rules"
1010
compsearch "github.com/algolia/cli/pkg/cmd/compositions/search"
11+
"github.com/algolia/cli/pkg/cmd/compositions/sortingstrategy"
1112
"github.com/algolia/cli/pkg/cmd/compositions/upsert"
1213
"github.com/algolia/cli/pkg/cmdutil"
1314
)
@@ -23,6 +24,7 @@ func NewCompositionsCmd(f *cmdutil.Factory) *cobra.Command {
2324
cmd.AddCommand(list.NewListCmd(f))
2425
cmd.AddCommand(get.NewGetCmd(f))
2526
cmd.AddCommand(upsert.NewUpsertCmd(f))
27+
cmd.AddCommand(sortingstrategy.NewSortingStrategyCmd(f))
2628
cmd.AddCommand(delete.NewDeleteCmd(f))
2729
cmd.AddCommand(compsearch.NewSearchCmd(f))
2830
cmd.AddCommand(rules.NewRulesCmd(f))
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package sortingstrategy
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/MakeNowJust/heredoc"
8+
algoliaComposition "github.com/algolia/algoliasearch-client-go/v4/algolia/composition"
9+
"github.com/spf13/cobra"
10+
11+
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
12+
"github.com/algolia/cli/pkg/cmdutil"
13+
"github.com/algolia/cli/pkg/config"
14+
"github.com/algolia/cli/pkg/interactive"
15+
"github.com/algolia/cli/pkg/iostreams"
16+
"github.com/algolia/cli/pkg/validators"
17+
)
18+
19+
// Options holds dependencies and flags for the sorting-strategy command.
20+
type Options struct {
21+
Config config.IConfig
22+
IO *iostreams.IOStreams
23+
CompositionClient func() (*algoliaComposition.APIClient, error)
24+
Prompter interactive.Prompter
25+
CompositionID string
26+
File string
27+
Interactive bool
28+
PrintFlags *cmdutil.PrintFlags
29+
}
30+
31+
// NewSortingStrategyCmd returns the `compositions sorting-strategy` command.
32+
func NewSortingStrategyCmd(f *cmdutil.Factory) *cobra.Command {
33+
opts := &Options{
34+
IO: f.IOStreams,
35+
Config: f.Config,
36+
CompositionClient: f.CompositionClient,
37+
Prompter: f.Prompter,
38+
PrintFlags: cmdutil.NewPrintFlags().WithDefaultOutput("json"),
39+
}
40+
41+
cmd := &cobra.Command{
42+
Use: "sorting-strategy <composition-id>",
43+
Short: "Set the sorting strategy of a composition",
44+
Long: heredoc.Doc(`
45+
Replace a composition's sorting strategy: a mapping of sort labels to the
46+
indices (or replicas) that implement them. These labels are what the
47+
` + "`sortBy`" + ` field on composition rules and search params selects at runtime.
48+
`),
49+
Args: validators.ExactArgsWithMsg(1, "compositions sorting-strategy requires a <composition-id> argument."),
50+
Annotations: map[string]string{
51+
"acls": "editSettings",
52+
},
53+
Example: heredoc.Doc(`
54+
# Set the sorting strategy from a JSON file
55+
$ algolia compositions sorting-strategy my-comp --file strategy.json
56+
57+
# Set it from stdin
58+
$ echo '{"Price (asc)":"products_price_asc"}' | algolia compositions sorting-strategy my-comp --file -
59+
60+
# Build the mapping interactively
61+
$ algolia compositions sorting-strategy my-comp --interactive
62+
`),
63+
RunE: func(cmd *cobra.Command, args []string) error {
64+
opts.CompositionID = args[0]
65+
66+
if opts.Interactive == (opts.File != "") {
67+
return cmdutil.FlagErrorf("exactly one of `--file` or `--interactive` is required")
68+
}
69+
if opts.Interactive && !opts.IO.CanPrompt() {
70+
return cmdutil.FlagErrorf("`--interactive` requires a terminal; use `--file` instead")
71+
}
72+
73+
return runCmd(opts)
74+
},
75+
}
76+
77+
cmd.Flags().StringVarP(&opts.File, "file", "f", "", "JSON file path (use - for stdin)")
78+
cmd.Flags().BoolVarP(&opts.Interactive, "interactive", "i", false, "Build the sorting strategy interactively")
79+
80+
opts.PrintFlags.AddFlags(cmd)
81+
return cmd
82+
}
83+
84+
// buildStrategy produces the label->index map either interactively or by reading
85+
// and parsing the JSON file.
86+
func buildStrategy(opts *Options) (map[string]string, error) {
87+
if opts.Interactive {
88+
// The body is a bare map; the interactive Builder needs a struct, so wrap
89+
// it and reuse the engine's map handling (count, then key/value pairs).
90+
var doc struct {
91+
SortingStrategy map[string]string `json:"sortingStrategy"`
92+
}
93+
prompter := opts.Prompter
94+
if prompter == nil {
95+
prompter = interactive.NewSurveyPrompter(opts.IO)
96+
}
97+
if err := (&interactive.Builder{Prompter: prompter}).Build(&doc); err != nil {
98+
return nil, fmt.Errorf("building sorting strategy: %w", err)
99+
}
100+
return doc.SortingStrategy, nil
101+
}
102+
103+
raw, err := cmdutil.ReadFile(opts.File, opts.IO.In)
104+
if err != nil {
105+
return nil, fmt.Errorf("reading file: %w", err)
106+
}
107+
var strategy map[string]string
108+
if err := json.Unmarshal(raw, &strategy); err != nil {
109+
return nil, fmt.Errorf("parsing sorting strategy JSON: %w", err)
110+
}
111+
return strategy, nil
112+
}
113+
114+
func runCmd(opts *Options) error {
115+
strategy, err := buildStrategy(opts)
116+
if err != nil {
117+
return err
118+
}
119+
120+
client, err := opts.CompositionClient()
121+
if err != nil {
122+
return err
123+
}
124+
125+
p, err := opts.PrintFlags.ToPrinter()
126+
if err != nil {
127+
return err
128+
}
129+
130+
opts.IO.StartProgressIndicatorWithLabel("Updating sorting strategy")
131+
132+
res, err := client.UpdateSortingStrategyComposition(
133+
client.NewApiUpdateSortingStrategyCompositionRequest(opts.CompositionID, strategy),
134+
)
135+
if err != nil {
136+
opts.IO.StopProgressIndicator()
137+
return err
138+
}
139+
140+
opts.IO.StopProgressIndicator()
141+
142+
if err := compinternal.WaitForTask(opts.IO, client, opts.CompositionID, res.TaskID, compinternal.PollInterval, compinternal.Timeout); err != nil {
143+
return err
144+
}
145+
146+
if opts.IO.IsStdoutTTY() {
147+
cs := opts.IO.ColorScheme()
148+
fmt.Fprintf(opts.IO.Out, "%s Updated sorting strategy for composition %s\n", cs.SuccessIcon(), opts.CompositionID)
149+
}
150+
151+
return p.Print(opts.IO, res)
152+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package sortingstrategy_test
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
13+
compinternal "github.com/algolia/cli/pkg/cmd/compositions/internal"
14+
"github.com/algolia/cli/pkg/cmd/compositions/sortingstrategy"
15+
"github.com/algolia/cli/pkg/httpmock"
16+
"github.com/algolia/cli/pkg/interactive"
17+
"github.com/algolia/cli/test"
18+
)
19+
20+
func TestSortingStrategy_File(t *testing.T) {
21+
r := &httpmock.Registry{}
22+
r.Register(httpmock.REST("POST", "1/compositions/my-comp/sortingStrategy"), httpmock.StringResponse(`{"taskID":7}`))
23+
r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/7"), httpmock.StringResponse(`{"status":"published"}`))
24+
25+
compinternal.PollInterval = 1 * time.Millisecond
26+
compinternal.Timeout = 50 * time.Millisecond
27+
t.Cleanup(func() {
28+
compinternal.PollInterval = compinternal.DefaultPollInterval
29+
compinternal.Timeout = compinternal.DefaultTimeout
30+
})
31+
32+
f, out := test.NewFactory(false, r, nil, "")
33+
cmd := sortingstrategy.NewSortingStrategyCmd(f)
34+
out.InBuf.WriteString(`{"Price (asc)":"products_price_asc"}`)
35+
_, err := test.Execute(cmd, "my-comp --file -", out)
36+
require.NoError(t, err)
37+
38+
assert.JSONEq(t, `{"taskID":7}`, strings.TrimSpace(out.String()))
39+
r.Verify(t)
40+
}
41+
42+
func TestSortingStrategy_Interactive(t *testing.T) {
43+
r := &httpmock.Registry{}
44+
var captured []byte
45+
r.Register(httpmock.REST("POST", "1/compositions/my-comp/sortingStrategy"), func(req *http.Request) (*http.Response, error) {
46+
captured, _ = io.ReadAll(req.Body)
47+
return httpmock.StringResponse(`{"taskID":8}`)(req)
48+
})
49+
r.Register(httpmock.REST("GET", "1/compositions/my-comp/task/8"), httpmock.StringResponse(`{"status":"published"}`))
50+
51+
compinternal.PollInterval = 1 * time.Millisecond
52+
compinternal.Timeout = 50 * time.Millisecond
53+
t.Cleanup(func() {
54+
compinternal.PollInterval = compinternal.DefaultPollInterval
55+
compinternal.Timeout = compinternal.DefaultTimeout
56+
})
57+
58+
f, out := test.NewFactory(true, r, nil, "")
59+
// One entry: label "Price (asc)" -> index "products_price_asc". Keys match
60+
// the engine's map prompt labels (count/key/value), as in TestBuild_StringMap.
61+
f.Prompter = &interactive.ScriptedPrompter{Inputs: map[string]string{
62+
"entries": "1",
63+
"key[0]": "Price (asc)",
64+
`["Price (asc)"]`: "products_price_asc",
65+
}}
66+
67+
cmd := sortingstrategy.NewSortingStrategyCmd(f)
68+
_, err := test.Execute(cmd, "my-comp --interactive", out)
69+
require.NoError(t, err)
70+
71+
assert.JSONEq(t, `{"Price (asc)":"products_price_asc"}`, string(captured))
72+
r.Verify(t)
73+
}
74+
75+
func TestSortingStrategy_InteractiveAndFileConflict(t *testing.T) {
76+
r := &httpmock.Registry{}
77+
f, out := test.NewFactory(true, r, nil, "")
78+
cmd := sortingstrategy.NewSortingStrategyCmd(f)
79+
_, err := test.Execute(cmd, "my-comp --file strategy.json --interactive", out)
80+
require.Error(t, err)
81+
assert.Contains(t, err.Error(), "exactly one of `--file` or `--interactive`")
82+
}
83+
84+
func TestSortingStrategy_InteractiveNoTTY(t *testing.T) {
85+
r := &httpmock.Registry{}
86+
f, out := test.NewFactory(false, r, nil, "")
87+
cmd := sortingstrategy.NewSortingStrategyCmd(f)
88+
_, err := test.Execute(cmd, "my-comp --interactive", out)
89+
require.Error(t, err)
90+
assert.Contains(t, err.Error(), "requires a terminal")
91+
}
92+
93+
func TestSortingStrategy_MissingArg(t *testing.T) {
94+
r := &httpmock.Registry{}
95+
f, out := test.NewFactory(false, r, nil, "")
96+
cmd := sortingstrategy.NewSortingStrategyCmd(f)
97+
_, err := test.Execute(cmd, "--file -", out)
98+
require.Error(t, err)
99+
assert.Contains(t, err.Error(), "requires a <composition-id> argument")
100+
}

0 commit comments

Comments
 (0)