Skip to content

Commit 83b2ae6

Browse files
authored
feat(document): add edit subcommand (#55)
Add `firestore document edit <path>` with two modes: - interactive: open $EDITOR with the document's JSON, full-replace on save - non-interactive: --set field=value (repeatable) partial-merges named fields via firestore Update, supports dotted paths and auto-typed values Errors if the document does not exist. Covered by unit tests for the value parser and integration tests for the non-interactive merge path. Co-authored-by: Gustavo Hoirisch <355877+gugahoi@users.noreply.github.com>
1 parent 160d948 commit 83b2ae6

4 files changed

Lines changed: 362 additions & 0 deletions

File tree

pkg/cmd/document/edit.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package document
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"strconv"
10+
"strings"
11+
12+
"cloud.google.com/go/firestore"
13+
"github.com/gugahoi/firestore/pkg/cmd/keys"
14+
"github.com/spf13/cobra"
15+
"google.golang.org/grpc/codes"
16+
"google.golang.org/grpc/status"
17+
)
18+
19+
func NewEditCmd() *cobra.Command {
20+
var set []string
21+
22+
cmd := &cobra.Command{
23+
Use: "edit <path>",
24+
Aliases: []string{"e"},
25+
Short: "edits a document, interactively in $EDITOR or via --set field=value",
26+
Args: cobra.ExactArgs(1),
27+
RunE: func(cmd *cobra.Command, args []string) error {
28+
client := cmd.Context().Value(keys.ClientKey).(*firestore.Client)
29+
if len(set) > 0 {
30+
return editFields(client, args[0], set)
31+
}
32+
return editInteractive(client, args[0])
33+
},
34+
}
35+
36+
cmd.Flags().StringSliceVar(&set, "set", nil, "field=value pair to update (repeatable, e.g. --set age=42 --set address.city=NYC)")
37+
return cmd
38+
}
39+
40+
// editFields partially updates the named fields, leaving all other fields untouched.
41+
func editFields(client *firestore.Client, path string, sets []string) error {
42+
ctx := context.Background()
43+
44+
updates := make([]firestore.Update, 0, len(sets))
45+
for _, s := range sets {
46+
u, err := parseSetArg(s)
47+
if err != nil {
48+
return err
49+
}
50+
updates = append(updates, u)
51+
}
52+
53+
docRef := client.Doc(strings.TrimPrefix(path, "/"))
54+
_, err := docRef.Update(ctx, updates)
55+
if err != nil {
56+
if status.Code(err) == codes.NotFound {
57+
return fmt.Errorf("document not found")
58+
}
59+
return fmt.Errorf("failed to update document: %v", err)
60+
}
61+
62+
return nil
63+
}
64+
65+
// editInteractive opens the current document contents in $EDITOR and writes the
66+
// edited JSON back, replacing the whole document.
67+
func editInteractive(client *firestore.Client, path string) error {
68+
ctx := context.Background()
69+
70+
docRef := client.Doc(strings.TrimPrefix(path, "/"))
71+
snap, err := docRef.Get(ctx)
72+
if err != nil {
73+
if status.Code(err) == codes.NotFound {
74+
return fmt.Errorf("document not found")
75+
}
76+
return fmt.Errorf("failed to read document: %v", err)
77+
}
78+
79+
editor, err := detectEditor()
80+
if err != nil {
81+
return err
82+
}
83+
84+
content, err := json.MarshalIndent(snap.Data(), "", " ")
85+
if err != nil {
86+
return fmt.Errorf("failed to format document: %v", err)
87+
}
88+
89+
tmpFile, err := os.CreateTemp("", "firestore-doc-*.json")
90+
if err != nil {
91+
return fmt.Errorf("failed to create temp file: %v", err)
92+
}
93+
defer os.Remove(tmpFile.Name())
94+
if _, err := tmpFile.Write(content); err != nil {
95+
tmpFile.Close()
96+
return fmt.Errorf("failed to write temp file: %v", err)
97+
}
98+
tmpFile.Close()
99+
100+
// strings.Fields handles editors with args, e.g. EDITOR="code --wait"
101+
parts := strings.Fields(editor)
102+
c := exec.Command(parts[0], append(parts[1:], tmpFile.Name())...)
103+
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
104+
if err := c.Run(); err != nil {
105+
return fmt.Errorf("editor exited with error: %v", err)
106+
}
107+
108+
edited, err := os.ReadFile(tmpFile.Name())
109+
if err != nil {
110+
return fmt.Errorf("failed to read edited file: %v", err)
111+
}
112+
113+
var data map[string]any
114+
if err := json.Unmarshal(edited, &data); err != nil {
115+
return fmt.Errorf("invalid JSON: %v", err)
116+
}
117+
118+
if _, err := docRef.Set(ctx, data); err != nil {
119+
return fmt.Errorf("failed to save document: %v", err)
120+
}
121+
122+
return nil
123+
}
124+
125+
// detectEditor finds the user's preferred editor.
126+
// ponytail: duplicated from browse/editor.go (~12 lines) — browse pulls in Bubble Tea, so
127+
// importing it here would drag the whole TUI into this lightweight command. Extract to a shared
128+
// pkg/cmd/editor package if a third caller ever needs it.
129+
func detectEditor() (string, error) {
130+
if editor := os.Getenv("EDITOR"); editor != "" {
131+
return editor, nil
132+
}
133+
if editor := os.Getenv("VISUAL"); editor != "" {
134+
return editor, nil
135+
}
136+
for _, editor := range []string{"vim", "vi", "nano"} {
137+
if _, err := exec.LookPath(editor); err == nil {
138+
return editor, nil
139+
}
140+
}
141+
return "", fmt.Errorf("no editor found (set $EDITOR)")
142+
}
143+
144+
// parseSetArg splits a "field=value" pair into a firestore.Update. The field may be a dotted
145+
// path (e.g. address.city); firestore.Update.Path interprets dots as field-path separators.
146+
func parseSetArg(s string) (firestore.Update, error) {
147+
field, value, found := strings.Cut(s, "=")
148+
if !found || field == "" {
149+
return firestore.Update{}, fmt.Errorf("invalid --set %q, expected field=value", s)
150+
}
151+
return firestore.Update{Path: field, Value: parseValue(value)}, nil
152+
}
153+
154+
// parseValue auto-detects the type of a value string: quoted->string, true/false->bool,
155+
// [..]->JSON array, numeric->number, anything else->bare string.
156+
func parseValue(s string) any {
157+
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
158+
var v string
159+
if err := json.Unmarshal([]byte(s), &v); err == nil {
160+
return v
161+
}
162+
}
163+
if s == "true" {
164+
return true
165+
}
166+
if s == "false" {
167+
return false
168+
}
169+
if len(s) >= 2 && s[0] == '[' {
170+
var arr []any
171+
if err := json.Unmarshal([]byte(s), &arr); err == nil {
172+
return arr
173+
}
174+
}
175+
if n, err := strconv.ParseFloat(s, 64); err == nil {
176+
return n
177+
}
178+
return s
179+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//go:build integration
2+
3+
package document
4+
5+
import (
6+
"context"
7+
"os"
8+
"reflect"
9+
"testing"
10+
11+
"cloud.google.com/go/firestore"
12+
)
13+
14+
// These tests run only against a Firestore emulator. Set FIRESTORE_EMULATOR_HOST
15+
// (e.g. localhost:8765) before running them.
16+
func emulatorClient(t *testing.T) *firestore.Client {
17+
t.Helper()
18+
if os.Getenv("FIRESTORE_EMULATOR_HOST") == "" {
19+
t.Skip("FIRESTORE_EMULATOR_HOST not set; skipping emulator integration test")
20+
}
21+
client, err := firestore.NewClient(context.Background(), "test-project")
22+
if err != nil {
23+
t.Fatalf("failed to create client: %v", err)
24+
}
25+
t.Cleanup(func() { client.Close() })
26+
return client
27+
}
28+
29+
func TestEditFields_PartialMerge(t *testing.T) {
30+
client := emulatorClient(t)
31+
ctx := context.Background()
32+
33+
path := "edit_users/alice"
34+
seed := map[string]any{
35+
"name": "Alice",
36+
"age": int64(30),
37+
"email": "alice@example.com",
38+
}
39+
if _, err := client.Doc(path).Set(ctx, seed); err != nil {
40+
t.Fatalf("seed: %v", err)
41+
}
42+
43+
if err := editFields(client, path, []string{"age=31", "active=true"}); err != nil {
44+
t.Fatalf("editFields: %v", err)
45+
}
46+
47+
snap, err := client.Doc(path).Get(ctx)
48+
if err != nil {
49+
t.Fatalf("get after edit: %v", err)
50+
}
51+
data := snap.Data()
52+
53+
// Updated fields. Numbers go through parseValue as float64 and read back as
54+
// float64 (Firestore preserves the stored double type).
55+
if got := data["age"]; got != float64(31) {
56+
t.Errorf("age = %v (%T), want float64(31)", got, got)
57+
}
58+
if got := data["active"]; got != true {
59+
t.Errorf("active = %v, want true", got)
60+
}
61+
// Untouched fields survive the partial merge.
62+
if got := data["name"]; got != "Alice" {
63+
t.Errorf("name = %v, want Alice (untouched)", got)
64+
}
65+
if got := data["email"]; got != "alice@example.com" {
66+
t.Errorf("email = %v, want alice@example.com (untouched)", got)
67+
}
68+
}
69+
70+
func TestEditFields_DottedPath(t *testing.T) {
71+
client := emulatorClient(t)
72+
ctx := context.Background()
73+
74+
path := "edit_users/bob"
75+
seed := map[string]any{
76+
"address": map[string]any{
77+
"city": "LA",
78+
"zip": "90001",
79+
},
80+
}
81+
if _, err := client.Doc(path).Set(ctx, seed); err != nil {
82+
t.Fatalf("seed: %v", err)
83+
}
84+
85+
if err := editFields(client, path, []string{"address.city=NYC"}); err != nil {
86+
t.Fatalf("editFields: %v", err)
87+
}
88+
89+
snap, err := client.Doc(path).Get(ctx)
90+
if err != nil {
91+
t.Fatalf("get after edit: %v", err)
92+
}
93+
addr, ok := snap.Data()["address"].(map[string]any)
94+
if !ok {
95+
t.Fatalf("address = %#v, want map", snap.Data()["address"])
96+
}
97+
if got := addr["city"]; got != "NYC" {
98+
t.Errorf("address.city = %v, want NYC", got)
99+
}
100+
// Sibling nested field untouched.
101+
if got := addr["zip"]; got != "90001" {
102+
t.Errorf("address.zip = %v, want 90001 (untouched)", got)
103+
}
104+
}
105+
106+
func TestEditFields_Array(t *testing.T) {
107+
client := emulatorClient(t)
108+
ctx := context.Background()
109+
110+
path := "edit_users/carol"
111+
if _, err := client.Doc(path).Set(ctx, map[string]any{"name": "Carol"}); err != nil {
112+
t.Fatalf("seed: %v", err)
113+
}
114+
115+
if err := editFields(client, path, []string{`tags=["a","b"]`}); err != nil {
116+
t.Fatalf("editFields: %v", err)
117+
}
118+
119+
snap, err := client.Doc(path).Get(ctx)
120+
if err != nil {
121+
t.Fatalf("get after edit: %v", err)
122+
}
123+
if got := snap.Data()["tags"]; !reflect.DeepEqual(got, []any{"a", "b"}) {
124+
t.Errorf("tags = %#v, want [a b]", got)
125+
}
126+
}
127+
128+
func TestEditFields_NotFound(t *testing.T) {
129+
client := emulatorClient(t)
130+
131+
err := editFields(client, "edit_users/missing", []string{"x=1"})
132+
if err == nil {
133+
t.Fatal("expected error for missing document, got nil")
134+
}
135+
if err.Error() != "document not found" {
136+
t.Errorf("error = %q, want %q", err.Error(), "document not found")
137+
}
138+
}

pkg/cmd/document/edit_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package document
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestParseValue(t *testing.T) {
9+
tests := []struct {
10+
in string
11+
want any
12+
}{
13+
{"foo", "foo"},
14+
{"42", float64(42)},
15+
{"3.14", 3.14},
16+
{"true", true},
17+
{"false", false},
18+
{`["a","b"]`, []any{"a", "b"}},
19+
{`"42"`, "42"}, // quotes force a string
20+
{"NYC", "NYC"},
21+
}
22+
for _, tt := range tests {
23+
got := parseValue(tt.in)
24+
if !reflect.DeepEqual(got, tt.want) {
25+
t.Errorf("parseValue(%q) = %#v, want %#v", tt.in, got, tt.want)
26+
}
27+
}
28+
}
29+
30+
func TestParseSetArg(t *testing.T) {
31+
u, err := parseSetArg("address.city=NYC")
32+
if err != nil {
33+
t.Fatalf("unexpected error: %v", err)
34+
}
35+
if u.Path != "address.city" || u.Value != "NYC" {
36+
t.Errorf("parseSetArg = {Path:%q Value:%v}, want {address.city NYC}", u.Path, u.Value)
37+
}
38+
39+
for _, bad := range []string{"noequals", "=value"} {
40+
if _, err := parseSetArg(bad); err == nil {
41+
t.Errorf("parseSetArg(%q) expected error, got nil", bad)
42+
}
43+
}
44+
}

pkg/cmd/document/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ func NewDocumentCmd() *cobra.Command {
1515
NewAddCmd(),
1616
NewCopyCmd(),
1717
NewDeleteCmd(),
18+
NewEditCmd(),
1819
NewGetCmd(),
1920
NewListCmd(),
2021
NewMoveCmd(),

0 commit comments

Comments
 (0)