From 0d9b1214bdb7bacc51a7a7446721c0bbe4517d6b Mon Sep 17 00:00:00 2001 From: "Christian G. Warden" Date: Fri, 28 Nov 2025 11:16:23 -0600 Subject: [PATCH] Add `record upsert` Command --- command/record.go | 50 +++++++++ lib/force.go | 25 +++++ lib/force_test.go | 270 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) diff --git a/command/record.go b/command/record.go index 6f53f58f..558a1322 100644 --- a/command/record.go +++ b/command/record.go @@ -15,6 +15,7 @@ func init() { recordCmd.AddCommand(recordGetCmd) recordCmd.AddCommand(recordCreateCmd) recordCmd.AddCommand(recordUpdateCmd) + recordCmd.AddCommand(recordUpsertCmd) recordCmd.AddCommand(recordDeleteCmd) recordCmd.AddCommand(recordMergeCmd) recordCmd.AddCommand(recordUndeleteCmd) @@ -65,6 +66,33 @@ var recordUpdateCmd = &cobra.Command{ }, } +var recordUpsertCmd = &cobra.Command{ + Use: "upsert : [:...]", + Short: "Upsert record using external ID", + Long: ` +Upsert (insert or update) a record using an external ID field. + +If a record with the given external ID value exists, it will be updated. +Otherwise, a new record will be created. + +Usage: + + force record upsert : [:...] +`, + Example: ` + force record upsert Account External_Id__c:ABC123 Name:"Acme Corp" Industry:Technology + force record upsert Contact Email:john@example.com FirstName:John LastName:Doe +`, + Args: cobra.MinimumNArgs(2), + DisableFlagsInUseLine: true, + Run: func(cmd *cobra.Command, args []string) { + object := args[0] + extIdPair := args[1] + fields := args[2:] + runRecordUpsert(object, extIdPair, fields) + }, +} + var recordDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete record", @@ -115,6 +143,7 @@ Usage: force record create [] force record update [] force record update : [] + force record upsert : [] force record delete force record merge force record undelete @@ -125,6 +154,7 @@ Usage: force record create User Name:"David Dollar" Phone:0000000000 force record update User 00Ei0000000000 State:GA force record update User username:user@name.org State:GA + force record upsert Account External_Id__c:ABC123 Name:"Acme Corp" force record delete User 00Ei0000000000 force record merge Contact 0033c00002YDNNWAA5 0033c00002YDPqkAAH force record undelete 0033c00002YDNNWAA5 @@ -158,6 +188,26 @@ func runRecordUpdate(object string, id string, fields []string) { fmt.Println("Record updated") } +func runRecordUpsert(object string, extIdPair string, fields []string) { + split := strings.SplitN(extIdPair, ":", 2) + if len(split) != 2 { + ErrorAndExit("Invalid external ID format. Use :") + } + extIdField := split[0] + extIdValue := split[1] + + attrs := parseArgumentAttrs(fields) + result, err := force.UpsertRecord(object, extIdField, extIdValue, attrs) + if err != nil { + ErrorAndExit("Failed to upsert record: %s", err.Error()) + } + if result.Created { + fmt.Printf("Record created: %s\n", result.Id) + } else { + fmt.Println("Record updated") + } +} + func runRecordMerge(object, masterId, duplicateId string) { err := force.Partner.Merge(object, masterId, duplicateId) if err != nil { diff --git a/lib/force.go b/lib/force.go index 6ff12049..13c5bc45 100644 --- a/lib/force.go +++ b/lib/force.go @@ -166,6 +166,12 @@ type ForceCreateRecordResult struct { Success bool } +type ForceUpsertResult struct { + Id string + Created bool + Success bool +} + type ForceLimits map[string]ForceLimit type ForceLimit struct { @@ -1340,6 +1346,25 @@ func (f *Force) UpdateRecord(sobject string, id string, attrs map[string]string) return } +func (f *Force) UpsertRecord(sobject string, externalIdField string, externalIdValue string, attrs map[string]string) (result ForceUpsertResult, err error) { + url := fmt.Sprintf("%s/services/data/%s/sobjects/%s/%s/%s", f.Credentials.InstanceUrl, apiVersion, sobject, externalIdField, externalIdValue) + body, err := f.httpPatch(url, attrs) + if err != nil { + return + } + // If body is empty, it was an update (HTTP 204) + if len(body) == 0 { + result.Created = false + result.Success = true + return + } + // Otherwise it was a create (HTTP 201) with the new Id + json.Unmarshal(body, &result) + result.Created = true + result.Success = true + return +} + func (f *Force) DeleteRecord(sobject string, id string) (err error) { url := fmt.Sprintf("%s/services/data/%s/sobjects/%s/%s", f.Credentials.InstanceUrl, apiVersion, sobject, id) _, err = f.httpDelete(url) diff --git a/lib/force_test.go b/lib/force_test.go index d148ae92..03fe6783 100644 --- a/lib/force_test.go +++ b/lib/force_test.go @@ -2,6 +2,9 @@ package lib import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" @@ -91,3 +94,270 @@ func TestAbortableQueryAndSend_Abort(t *testing.T) { t.Fatalf("expected first record v=1, got %v", recs[0]["v"]) } } + +func TestUpsertRecord_Create(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("Expected PATCH request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Account/External_Id__c/ABC123" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "001xx000003DGbYAAW", + "success": true, + "created": true, + }) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + result, err := force.UpsertRecord("Account", "External_Id__c", "ABC123", map[string]string{ + "Name": "Test Account", + }) + + if err != nil { + t.Fatalf("UpsertRecord returned error: %v", err) + } + + if !result.Created { + t.Error("Expected Created to be true for new record") + } + + if !result.Success { + t.Error("Expected Success to be true") + } + + if result.Id != "001xx000003DGbYAAW" { + t.Errorf("Expected Id 001xx000003DGbYAAW, got %s", result.Id) + } +} + +func TestUpsertRecord_Update(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("Expected PATCH request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Contact/Email/test@example.com" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + result, err := force.UpsertRecord("Contact", "Email", "test@example.com", map[string]string{ + "FirstName": "John", + "LastName": "Doe", + }) + + if err != nil { + t.Fatalf("UpsertRecord returned error: %v", err) + } + + if result.Created { + t.Error("Expected Created to be false for updated record") + } + + if !result.Success { + t.Error("Expected Success to be true") + } +} + +func TestUpsertRecord_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode([]map[string]interface{}{ + { + "message": "Required fields are missing: [Name]", + "errorCode": "REQUIRED_FIELD_MISSING", + }, + }) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + _, err := force.UpsertRecord("Account", "External_Id__c", "ABC123", map[string]string{}) + + if err == nil { + t.Fatal("Expected error for bad request, got nil") + } + + expectedMsg := "Required fields are missing: [Name]" + if err.Error() != expectedMsg { + t.Errorf("Expected error message %q, got %q", expectedMsg, err.Error()) + } +} + +func TestCreateRecord(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Account" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "001xx000003DGbZAAW", + "success": true, + }) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + id, err, _ := force.CreateRecord("Account", map[string]string{ + "Name": "New Account", + }) + + if err != nil { + t.Fatalf("CreateRecord returned error: %v", err) + } + + if id != "001xx000003DGbZAAW" { + t.Errorf("Expected Id 001xx000003DGbZAAW, got %s", id) + } +} + +func TestUpdateRecord(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("Expected PATCH request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Account/001xx000003DGbYAAW" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + err := force.UpdateRecord("Account", "001xx000003DGbYAAW", map[string]string{ + "Name": "Updated Account", + }) + + if err != nil { + t.Fatalf("UpdateRecord returned error: %v", err) + } +} + +func TestDeleteRecord(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("Expected DELETE request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Account/001xx000003DGbYAAW" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + err := force.DeleteRecord("Account", "001xx000003DGbYAAW") + + if err != nil { + t.Fatalf("DeleteRecord returned error: %v", err) + } +} + +func TestGetRecord(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + + expectedPath := "/services/data/" + ApiVersion() + "/sobjects/Account/001xx000003DGbYAAW" + if r.URL.Path != expectedPath { + t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "Id": "001xx000003DGbYAAW", + "Name": "Test Account", + "attributes": map[string]interface{}{ + "type": "Account", + "url": "/services/data/v62.0/sobjects/Account/001xx000003DGbYAAW", + }, + }) + })) + defer server.Close() + + force := &Force{ + Credentials: &ForceSession{ + InstanceUrl: server.URL, + AccessToken: "test-token", + }, + } + + record, err := force.GetRecord("Account", "001xx000003DGbYAAW") + + if err != nil { + t.Fatalf("GetRecord returned error: %v", err) + } + + if record["Id"] != "001xx000003DGbYAAW" { + t.Errorf("Expected Id 001xx000003DGbYAAW, got %v", record["Id"]) + } + + if record["Name"] != "Test Account" { + t.Errorf("Expected Name 'Test Account', got %v", record["Name"]) + } +}