Repository: amp-labs/connectors
Pull request #1590 (2025-04-24 00:30:47)

fix: (Salesforce) Normalize compound field names
This adds logic to normalize field names, so that updates generated by Salesforce compound fields will strip out the compound field prefix.

jlimatampersand: the logic itself makes sense. Just to confirm. 

This is assuming that we are adding `firstname` or `lastname` in `requiredWatchFields` right? So that we can make the amp.yaml to free from concern to add compound field anywhere? eg) read mapping, and subscribe watch fields? 
eberle1080: > the logic itself makes sense. Just to confirm.
> 
> This is assuming that we are adding `firstname` or `lastname` in `requiredWatchFields` right? So that we can make the amp.yaml to free from concern to add compound field anywhere? eg) read mapping, and subscribe watch fields?

That's right. And they can still refer to compound fields if they want, but only the top-level field (e.g. "Name"), not the sub-fields (e.g. "Name.FirstName")

Patch:
diff --git a/providers/salesforce/subscriptionEvent.go b/providers/salesforce/subscriptionEvent.go
index 7d55ed41..4918d4c5 100644
--- a/providers/salesforce/subscriptionEvent.go
+++ b/providers/salesforce/subscriptionEvent.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/amp-labs/connectors/common"
 	"github.com/amp-labs/connectors/internal/goutils"
@@ -170,6 +171,156 @@ func (s SubscriptionEvent) EventTimeStampNano() (int64, error) {
 	return int64(num), nil
 }
 
+// compoundField represents a compound field in Salesforce.
+type compoundField struct {
+	Object string // The object name
+	Field  string // The field name
+}
+
+// Note that this list was obtained by running this SOQL query:
+// SELECT EntityDefinition.QualifiedApiName,QualifiedApiName
+// FROM FieldDefinition
+// WHERE IsCompound = true AND EntityDefinition.QualifiedApiName LIKE '%'
+//
+// I then massaged the results in to actual Go code.
+var compoundFields = []compoundField{ //nolint:gochecknoglobals
+	{Object: "Account", Field: "BillingAddress"},
+	{Object: "Account", Field: "Name"},
+	{Object: "Account", Field: "ShippingAddress"},
+	{Object: "AccountChangeEvent", Field: "BillingAddress"},
+	{Object: "AccountChangeEvent", Field: "Name"},
+	{Object: "AccountChangeEvent", Field: "ShippingAddress"},
+	{Object: "AccountCleanInfo", Field: "Address"},
+	{Object: "AccountCleanInfoChangeEvent", Field: "Address"},
+	{Object: "Address", Field: "Address"},
+	{Object: "AlternativePaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "Asset", Field: "Address"},
+	{Object: "AssetChangeEvent", Field: "Address"},
+	{Object: "CardPaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "CartDeliveryGroup", Field: "DeliverToAddress"},
+	{Object: "CartDeliveryGroupChangeEvent", Field: "DeliverToAddress"},
+	{Object: "Contact", Field: "MailingAddress"},
+	{Object: "Contact", Field: "Name"},
+	{Object: "Contact", Field: "OtherAddress"},
+	{Object: "ContactChangeEvent", Field: "MailingAddress"},
+	{Object: "ContactChangeEvent", Field: "Name"},
+	{Object: "ContactChangeEvent", Field: "OtherAddress"},
+	{Object: "ContactCleanInfo", Field: "Address"},
+	{Object: "ContactCleanInfoChangeEvent", Field: "Address"},
+	{Object: "ContactPointAddress", Field: "Address"},
+	{Object: "ContactPointAddressChangeEvent", Field: "Address"},
+	{Object: "Contract", Field: "BillingAddress"},
+	{Object: "ContractChangeEvent", Field: "BillingAddress"},
+	{Object: "DandBCompany", Field: "Address"},
+	{Object: "DandBCompany", Field: "MailingAddress"},
+	{Object: "DigitalWallet", Field: "PaymentMethodAddress"},
+	{Object: "FulfillmentOrder", Field: "FulfilledToAddress"},
+	{Object: "FulfillmentOrderChangeEvent", Field: "FulfilledToAddress"},
+	{Object: "Individual", Field: "Name"},
+	{Object: "IndividualChangeEvent", Field: "Name"},
+	{Object: "Lead", Field: "Address"},
+	{Object: "Lead", Field: "Name"},
+	{Object: "LeadChangeEvent", Field: "Address"},
+	{Object: "LeadChangeEvent", Field: "Name"},
+	{Object: "LeadCleanInfo", Field: "Address"},
+	{Object: "LegalEntity", Field: "LegalEntityAddress"},
+	{Object: "Location", Field: "Location"},
+	{Object: "LocationChangeEvent", Field: "Location"},
+	{Object: "Name", Field: "Name"},
+	{Object: "Opportunity", Field: "Fiscal"},
+	{Object: "Order", Field: "BillingAddress"},
+	{Object: "Order", Field: "ShippingAddress"},
+	{Object: "OrderChangeEvent", Field: "BillingAddress"},
+	{Object: "OrderChangeEvent", Field: "ShippingAddress"},
+	{Object: "Organization", Field: "Address"},
+	{Object: "PaymentMethod", Field: "PaymentMethodAddress"},
+	{Object: "RecentlyViewed", Field: "Name"},
+	{Object: "ResourceAbsence", Field: "Address"},
+	{Object: "ResourceAbsenceChangeEvent", Field: "Address"},
+	{Object: "ReturnOrder", Field: "ShipFromAddress"},
+	{Object: "ReturnOrderChangeEvent", Field: "ShipFromAddress"},
+	{Object: "ServiceAppointment", Field: "Address"},
+	{Object: "ServiceAppointmentChangeEvent", Field: "Address"},
+	{Object: "ServiceContract", Field: "BillingAddress"},
+	{Object: "ServiceContract", Field: "ShippingAddress"},
+	{Object: "ServiceContractChangeEvent", Field: "BillingAddress"},
+	{Object: "ServiceContractChangeEvent", Field: "ShippingAddress"},
+	{Object: "ServiceTerritory", Field: "Address"},
+	{Object: "ServiceTerritoryChangeEvent", Field: "Address"},
+	{Object: "ServiceTerritoryMember", Field: "Address"},
+	{Object: "ServiceTerritoryMemberChangeEvent", Field: "Address"},
+	{Object: "User", Field: "Address"},
+	{Object: "User", Field: "Name"},
+	{Object: "UserChangeEvent", Field: "Address"},
+	{Object: "UserChangeEvent", Field: "Name"},
+	{Object: "WebCart", Field: "BillingAddress"},
+	{Object: "WebCartChangeEvent", Field: "BillingAddress"},
+	{Object: "WorkOrder", Field: "Address"},
+	{Object: "WorkOrderChangeEvent", Field: "Address"},
+	{Object: "WorkOrderLineItem", Field: "Address"},
+	{Object: "WorkOrderLineItemChangeEvent", Field: "Address"},
+}
+
+// Maps object name -> field name -> empty struct.
+// NB: All names are lowercase to allow case-insensitive matching.
+var compositePrefixMap map[string]map[string]struct{} //nolint:gochecknoglobals
+
+func init() {
+	compositePrefixMap = make(map[string]map[string]struct{}, len(compoundFields))
+
+	for _, field := range compoundFields {
+		obj := strings.ToLower(field.Object)
+		fld := strings.ToLower(field.Field)
+
+		if _, ok := compositePrefixMap[field.Object]; !ok {
+			compositePrefixMap[obj] = make(map[string]struct{})
+		}
+
+		compositePrefixMap[obj][fld] = struct{}{}
+	}
+}
+
+// isStandardCompoundField checks if the given object and field
+// are part of the standard compound fields defined in Salesforce.
+//
+// See https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/compound_fields.htm
+func isStandardCompoundField(obj, field string) bool {
+	fields, ok := compositePrefixMap[strings.ToLower(obj)]
+	if !ok {
+		return false
+	}
+
+	_, ok = fields[strings.ToLower(field)]
+
+	return ok
+}
+
+func (s SubscriptionEvent) normalizeUpdatedFieldName(name string) (string, error) {
+	if !strings.Contains(name, ".") {
+		return name, nil
+	}
+
+	// Compound fields look like "Field.Subfield"
+	// We're interested in the rightmost part, but to validate
+	// that indeed it's a compound field, we have to consider the
+	// leftmost part first.
+	parts := strings.SplitN(name, ".", 2) //nolint:mnd,gomnd
+	if len(parts) < 2 {                   //nolint:mnd,gomnd
+		return parts[0], nil
+	}
+
+	obj, err := s.ObjectName()
+	if err != nil {
+		return "", fmt.Errorf("failed to get object name: %w", err)
+	}
+
+	if !isStandardCompoundField(obj, parts[0]) {
+		return name, nil
+	}
+
+	return parts[1], nil
+}
+
 func (s SubscriptionEvent) UpdatedFields() ([]string, error) {
 	registry, err := s.asMap()
 	if err != nil {
@@ -200,7 +351,12 @@ func (s SubscriptionEvent) UpdatedFields() ([]string, error) {
 			)
 		}
 
-		fields[i] = str
+		fieldName, err := s.normalizeUpdatedFieldName(str)
+		if err != nil {
+			return nil, fmt.Errorf("failed to normalize field named %q: %w", str, err)
+		}
+
+		fields[i] = fieldName
 	}
 
 	return fields, nil


Test patch:
diff --git a/providers/salesforce/subscriptionEvent_test.go b/providers/salesforce/subscriptionEvent_test.go
index 82fbf416..1470fff2 100644
--- a/providers/salesforce/subscriptionEvent_test.go
+++ b/providers/salesforce/subscriptionEvent_test.go
@@ -9,6 +9,45 @@ import (
 	"gotest.tools/v3/assert"
 )
 
+func TestSubscriptionEventUpdate(t *testing.T) {
+	t.Parallel()
+
+	data := testutils.DataFromFile(t, "subscription/update_user.json")
+
+	event := SubscriptionEvent{}
+	if err := json.Unmarshal(data, &event); err != nil {
+		t.Fatalf("failed to start a test, cannot parse data; error (%v)", err)
+	}
+
+	eventType, err := event.EventType()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, eventType, common.SubscriptionEventTypeUpdate, "EventType should be Update")
+
+	rawEventType, err := event.RawEventName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, rawEventType, "UPDATE", "RawEventName should be UPDATE")
+
+	objectName, err := event.ObjectName()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, objectName, "User", "ObjectName should be User")
+
+	workspace, err := event.Workspace()
+
+	assert.NilError(t, err, "error should be nil")
+	assert.Equal(t, workspace, "", "Workspace should be empty")
+
+	fields, err := event.UpdatedFields()
+	assert.NilError(t, err, "error should be nil")
+
+	assert.Equal(t, len(fields), 3, "should have one updated field")
+	assert.Equal(t, fields[0], "LastModifiedDate", "first field name should be LastModifiedDate")
+	assert.Equal(t, fields[1], "LastModifiedById", "second field name should be LastModifiedById")
+	assert.Equal(t, fields[2], "FirstName", "third field name should be FirstName")
+}
+
 func TestSubscriptionEventProperties(t *testing.T) {
 	t.Parallel()
 
diff --git a/providers/salesforce/test/subscription/update_user.json b/providers/salesforce/test/subscription/update_user.json
new file mode 100644
index 00000000..a8c7f833
--- /dev/null
+++ b/providers/salesforce/test/subscription/update_user.json
@@ -0,0 +1,23 @@
+{
+  "ChangeEventHeader": {
+    "changeOrigin": "",
+    "changeType": "UPDATE",
+    "changedFields": [
+      "LastModifiedDate",
+      "LastModifiedById",
+      "Name.FirstName"
+    ],
+    "commitNumber": 1745433814682845200,
+    "commitTimestamp": 1745433814000,
+    "commitUser": "005ak00000EpjsfAAB",
+    "entityName": "User",
+    "recordId": "005ak00000GGsRRAA1",
+    "sequenceNumber": 1,
+    "transactionKey": "0000819a-2c4f-e547-290a-8ac966ffa9b9"
+  },
+  "LastModifiedById": "005ak00000EpjsfAAB",
+  "LastModifiedDate": "2025-04-23T18:43:34.000Z",
+  "Name": {
+    "FirstName": "BUTTERBALL"
+  }
+}