diff --git a/service/entityresolution/multi-strategy/output_mapper.go b/service/entityresolution/multi-strategy/output_mapper.go index 56f95e5b7d..a2d96adf1d 100644 --- a/service/entityresolution/multi-strategy/output_mapper.go +++ b/service/entityresolution/multi-strategy/output_mapper.go @@ -2,9 +2,8 @@ package multistrategy import ( "errors" - "fmt" - "strings" + "github.com/opentdf/platform/service/entityresolution/multi-strategy/transformation" "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" ) @@ -38,9 +37,14 @@ func (om *OutputMapper) MapResult(rawResult *types.RawResult, outputMappings []t entityResult.Metadata[key] = value } + // Resolve provider type for transformation dispatch. Providers stamp + // this in RawResult.Metadata; empty string is safe and falls back to + // common transformations only. + providerType, _ := rawResult.Metadata["provider_type"].(string) + // Apply output mappings for _, mapping := range outputMappings { - if err := om.applyMapping(rawResult, entityResult, mapping); err != nil { + if err := om.applyMapping(rawResult, entityResult, mapping, providerType); err != nil { return nil, types.WrapMultiStrategyError( types.ErrorTypeMapping, "failed to apply output mapping", @@ -62,7 +66,7 @@ func (om *OutputMapper) MapResult(rawResult *types.RawResult, outputMappings []t } // applyMapping applies a single output mapping rule -func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *types.EntityResult, mapping types.OutputMapping) error { +func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *types.EntityResult, mapping types.OutputMapping, providerType string) error { // Get source value based on provider type sourceValue, err := om.getSourceValue(rawResult, mapping) if err != nil { @@ -78,8 +82,9 @@ func (om *OutputMapper) applyMapping(rawResult *types.RawResult, entityResult *t return nil } - // Apply transformation if specified - transformedValue, err := om.applyTransformation(sourceValue, mapping.Transformation) + // Apply transformation via the shared registry so all provider-specific + // and common transformations stay in one place. + transformedValue, err := transformation.DefaultRegistry.ApplyTransformation(sourceValue, mapping.Transformation, providerType) if err != nil { return types.WrapMultiStrategyError( types.ErrorTypeMapping, @@ -129,187 +134,3 @@ func (om *OutputMapper) getSourceValue(rawResult *types.RawResult, mapping types return value, nil } - -// applyTransformation applies the specified transformation to the source value -func (om *OutputMapper) applyTransformation(value interface{}, transformation string) (interface{}, error) { - if transformation == "" { - return value, nil - } - - switch strings.ToLower(transformation) { - case "array": - return om.transformToArray(value) - - case "csv_to_array": - return om.transformCSVToArray(value) - - case "ldap_dn_to_cn_array": - return om.transformLDAPDNToCNArray(value) - - case "lowercase": - return om.transformToLowercase(value) - - case "uppercase": - return om.transformToUppercase(value) - - case "trim": - return om.transformTrim(value) - - default: - return nil, fmt.Errorf("unknown transformation: %s", transformation) - } -} - -// transformToArray ensures the value is an array -func (om *OutputMapper) transformToArray(value interface{}) (interface{}, error) { - if value == nil { - return []interface{}{}, nil - } - - // If already an array, return as-is - if arr, ok := value.([]interface{}); ok { - return arr, nil - } - - // If string array, convert to interface array - if strArr, ok := value.([]string); ok { - result := make([]interface{}, len(strArr)) - for i, s := range strArr { - result[i] = s - } - return result, nil - } - - // Otherwise, wrap single value in array - return []interface{}{value}, nil -} - -// transformCSVToArray splits a CSV string into an array -func (om *OutputMapper) transformCSVToArray(value interface{}) (interface{}, error) { - if value == nil { - return []interface{}{}, nil - } - - // Convert to string - str, ok := value.(string) - if !ok { - return nil, fmt.Errorf("csv_to_array transformation requires string input, got %T", value) - } - - // Split by comma and trim whitespace - parts := strings.Split(str, ",") - result := make([]interface{}, 0, len(parts)) - - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - result = append(result, trimmed) - } - } - - return result, nil -} - -// transformLDAPDNToCNArray extracts CN values from LDAP DN strings -func (om *OutputMapper) transformLDAPDNToCNArray(value interface{}) (interface{}, error) { - if value == nil { - return []interface{}{}, nil - } - - // Handle array of DNs - if arr, ok := value.([]interface{}); ok { - result := make([]interface{}, 0) - for _, item := range arr { - if itemStr, itemOk := item.(string); itemOk { - cn := om.extractCNFromDN(itemStr) - if cn != "" { - result = append(result, cn) - } - } - } - return result, nil - } - - // Handle string array - if strArr, ok := value.([]string); ok { - result := make([]interface{}, 0) - for _, str := range strArr { - cn := om.extractCNFromDN(str) - if cn != "" { - result = append(result, cn) - } - } - return result, nil - } - - // Handle single DN string - if str, ok := value.(string); ok { - cn := om.extractCNFromDN(str) - if cn != "" { - return []interface{}{cn}, nil - } - return []interface{}{}, nil - } - - return nil, fmt.Errorf("ldap_dn_to_cn_array transformation requires string or array input, got %T", value) -} - -// transformToLowercase converts string values to lowercase -func (om *OutputMapper) transformToLowercase(value interface{}) (interface{}, error) { - if value == nil { - return "", nil // Return empty string for nil values in lowercase transformation - } - - if str, ok := value.(string); ok { - return strings.ToLower(str), nil - } - - return nil, fmt.Errorf("lowercase transformation requires string input, got %T", value) -} - -// transformToUppercase converts string values to uppercase -func (om *OutputMapper) transformToUppercase(value interface{}) (interface{}, error) { - if value == nil { - return "", nil // Return empty string for nil values in uppercase transformation - } - - if str, ok := value.(string); ok { - return strings.ToUpper(str), nil - } - - return nil, fmt.Errorf("uppercase transformation requires string input, got %T", value) -} - -// transformTrim trims whitespace from string values -func (om *OutputMapper) transformTrim(value interface{}) (interface{}, error) { - if value == nil { - return "", nil // Return empty string for nil values in trim transformation - } - - if str, ok := value.(string); ok { - return strings.TrimSpace(str), nil - } - - return nil, fmt.Errorf("trim transformation requires string input, got %T", value) -} - -// extractCNFromDN extracts the CN (Common Name) component from an LDAP DN -func (om *OutputMapper) extractCNFromDN(dn string) string { - // Simple CN extraction - looks for CN= at the beginning or after comma - dn = strings.TrimSpace(dn) - if dn == "" { - return "" - } - - // Split DN into components - components := strings.Split(dn, ",") - - for _, component := range components { - component = strings.TrimSpace(component) - if strings.HasPrefix(strings.ToUpper(component), "CN=") { - return strings.TrimSpace(component[3:]) - } - } - - return "" -} diff --git a/service/entityresolution/multi-strategy/output_mapper_test.go b/service/entityresolution/multi-strategy/output_mapper_test.go new file mode 100644 index 0000000000..3297e691e4 --- /dev/null +++ b/service/entityresolution/multi-strategy/output_mapper_test.go @@ -0,0 +1,141 @@ +package multistrategy + +import ( + "reflect" + "testing" + + "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" +) + +func TestOutputMapper_MapResult_PostgresObject(t *testing.T) { + om := NewOutputMapper() + raw := &types.RawResult{ + Data: map[string]interface{}{ + "attributes": `{"department":"Engineering","clearance":"secret"}`, + }, + Metadata: map[string]interface{}{ + "provider_type": "sql", + }, + } + mappings := []types.OutputMapping{ + {SourceColumn: "attributes", ClaimName: "user_attributes", Transformation: "postgres_object"}, + } + + result, err := om.MapResult(raw, mappings, "entity-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := map[string]any{ + "department": "Engineering", + "clearance": "secret", + } + got, ok := result.Claims["user_attributes"].(map[string]any) + if !ok { + t.Fatalf("expected map[string]any claim, got %T", result.Claims["user_attributes"]) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("claims mismatch: got %#v, want %#v", got, want) + } +} + +func TestOutputMapper_MapResult_PostgresArray(t *testing.T) { + om := NewOutputMapper() + raw := &types.RawResult{ + Data: map[string]interface{}{ + "groups": "{admin,user,finance}", + }, + Metadata: map[string]interface{}{ + "provider_type": "sql", + }, + } + mappings := []types.OutputMapping{ + {SourceColumn: "groups", ClaimName: "group_memberships", Transformation: "postgres_array"}, + } + + result, err := om.MapResult(raw, mappings, "entity-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{"admin", "user", "finance"} + got, ok := result.Claims["group_memberships"].([]string) + if !ok { + t.Fatalf("expected []string claim, got %T", result.Claims["group_memberships"]) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("claims mismatch: got %#v, want %#v", got, want) + } +} + +func TestOutputMapper_MapResult_CommonTransformations(t *testing.T) { + om := NewOutputMapper() + raw := &types.RawResult{ + Data: map[string]interface{}{ + "email": " User@Example.COM ", + "roles": "admin,,analyst , reviewer", + "nickname": " bob ", + }, + Metadata: map[string]interface{}{ + "provider_type": "sql", + }, + } + mappings := []types.OutputMapping{ + {SourceColumn: "email", ClaimName: "email_lower", Transformation: "lowercase"}, + {SourceColumn: "roles", ClaimName: "roles", Transformation: "csv_to_array"}, + {SourceColumn: "nickname", ClaimName: "nickname", Transformation: "trim"}, + } + + result, err := om.MapResult(raw, mappings, "entity-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := result.Claims["email_lower"]; got != " user@example.com " { + t.Errorf("email_lower: got %v", got) + } + if got, ok := result.Claims["roles"].([]string); !ok || !reflect.DeepEqual(got, []string{"admin", "analyst", "reviewer"}) { + t.Errorf("roles: got %#v", result.Claims["roles"]) + } + if got := result.Claims["nickname"]; got != "bob" { + t.Errorf("nickname: got %v", got) + } +} + +func TestOutputMapper_MapResult_UnknownTransformationErrors(t *testing.T) { + om := NewOutputMapper() + raw := &types.RawResult{ + Data: map[string]interface{}{"x": "y"}, + Metadata: map[string]interface{}{"provider_type": "sql"}, + } + mappings := []types.OutputMapping{ + {SourceColumn: "x", ClaimName: "x", Transformation: "does_not_exist"}, + } + + if _, err := om.MapResult(raw, mappings, "entity-1"); err == nil { + t.Fatal("expected error for unknown transformation, got nil") + } +} + +func TestOutputMapper_MapResult_MissingSourceIsSkipped(t *testing.T) { + om := NewOutputMapper() + raw := &types.RawResult{ + Data: map[string]interface{}{"present": "yes"}, + Metadata: map[string]interface{}{"provider_type": "sql"}, + } + mappings := []types.OutputMapping{ + {SourceColumn: "present", ClaimName: "present"}, + {SourceColumn: "absent", ClaimName: "absent"}, + } + + result, err := om.MapResult(raw, mappings, "entity-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, exists := result.Claims["absent"]; exists { + t.Error("absent claim should not be set") + } + if result.Claims["present"] != "yes" { + t.Errorf("present: got %v", result.Claims["present"]) + } +} diff --git a/service/entityresolution/multi-strategy/providers/claims/claims_mapper_test.go b/service/entityresolution/multi-strategy/providers/claims/claims_mapper_test.go index fec1b94da8..325a4a7743 100644 --- a/service/entityresolution/multi-strategy/providers/claims/claims_mapper_test.go +++ b/service/entityresolution/multi-strategy/providers/claims/claims_mapper_test.go @@ -356,6 +356,7 @@ func TestClaimsMapper_GetSupportedTransformations(t *testing.T) { "string", "lowercase", "uppercase", + "trim", "jwt_extract_scope", "jwt_normalize_groups", } diff --git a/service/entityresolution/multi-strategy/providers/ldap/ldap_mapper_test.go b/service/entityresolution/multi-strategy/providers/ldap/ldap_mapper_test.go index ab2d2f9514..3f286a1153 100644 --- a/service/entityresolution/multi-strategy/providers/ldap/ldap_mapper_test.go +++ b/service/entityresolution/multi-strategy/providers/ldap/ldap_mapper_test.go @@ -381,6 +381,7 @@ func TestLDAPMapper_GetSupportedTransformations(t *testing.T) { "string", "lowercase", "uppercase", + "trim", // LDAP-specific transformations "ldap_dn_to_cn_array", "ldap_dn_to_cn", diff --git a/service/entityresolution/multi-strategy/providers/sql/sql_mapper_test.go b/service/entityresolution/multi-strategy/providers/sql/sql_mapper_test.go index 37df292c93..33c966281d 100644 --- a/service/entityresolution/multi-strategy/providers/sql/sql_mapper_test.go +++ b/service/entityresolution/multi-strategy/providers/sql/sql_mapper_test.go @@ -1,6 +1,7 @@ package sql import ( + "reflect" "testing" "github.com/opentdf/platform/service/entityresolution/multi-strategy/types" @@ -142,6 +143,22 @@ func TestSQLMapper_TransformResults(t *testing.T) { }, expectError: false, }, + { + name: "PostgreSQL object (JSON) transformation", + rawData: map[string]interface{}{ + "attributes": `{"department":"Engineering","clearance":"secret"}`, + }, + outputMapping: []types.OutputMapping{ + {SourceColumn: "attributes", ClaimName: "user_attributes", Transformation: "postgres_object"}, + }, + expectedClaims: map[string]interface{}{ + "user_attributes": map[string]any{ + "department": "Engineering", + "clearance": "secret", + }, + }, + expectError: false, + }, { name: "CSV to array transformation", rawData: map[string]interface{}{ @@ -220,7 +237,7 @@ func verifyClaimValue(t *testing.T, claims map[string]interface{}, key string, e // Handle slice comparison expectedSlice, isSlice := expectedValue.([]string) if !isSlice { - if actualValue != expectedValue { + if !reflect.DeepEqual(actualValue, expectedValue) { t.Errorf("Claim %s: expected %v, got %v", key, expectedValue, actualValue) } return @@ -374,8 +391,10 @@ func TestSQLMapper_GetSupportedTransformations(t *testing.T) { "string", "lowercase", "uppercase", + "trim", // SQL-specific transformations "postgres_array", + "postgres_object", } if len(transformations) != len(expectedTransformations) { diff --git a/service/entityresolution/multi-strategy/transformation/common.go b/service/entityresolution/multi-strategy/transformation/common.go index 91be696af0..97ffc9bf29 100644 --- a/service/entityresolution/multi-strategy/transformation/common.go +++ b/service/entityresolution/multi-strategy/transformation/common.go @@ -18,12 +18,16 @@ func ApplyCommonTransformation(value interface{}, transformation string) (interf return ApplyLowercase(value) case CommonUppercase: return ApplyUppercase(value) + case CommonTrim: + return ApplyTrim(value) default: return nil, fmt.Errorf("unsupported common transformation: %s", transformation) } } -// ApplyCSVToArray converts comma-separated strings to string arrays +// ApplyCSVToArray converts comma-separated strings to string arrays. +// Whitespace is trimmed from each entry and empty entries are dropped so that +// inputs like "a,,b" yield ["a", "b"]. func ApplyCSVToArray(value interface{}) (interface{}, error) { str, ok := value.(string) if !ok { @@ -35,10 +39,14 @@ func ApplyCSVToArray(value interface{}) (interface{}, error) { } parts := strings.Split(str, ",") - for i, part := range parts { - parts[i] = strings.TrimSpace(part) + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } } - return parts, nil + return result, nil } // ApplyArray ensures the value is returned as an array type @@ -81,3 +89,11 @@ func ApplyUppercase(value interface{}) (interface{}, error) { } return strings.ToUpper(fmt.Sprintf("%v", value)), nil } + +// ApplyTrim trims leading and trailing whitespace from string values. +func ApplyTrim(value interface{}) (interface{}, error) { + if str, ok := value.(string); ok { + return strings.TrimSpace(str), nil + } + return nil, fmt.Errorf("trim transformation requires string input, got %T", value) +} diff --git a/service/entityresolution/multi-strategy/transformation/common_test.go b/service/entityresolution/multi-strategy/transformation/common_test.go index e70d18a6bd..d844a1d60d 100644 --- a/service/entityresolution/multi-strategy/transformation/common_test.go +++ b/service/entityresolution/multi-strategy/transformation/common_test.go @@ -36,6 +36,12 @@ func TestApplyCSVToArray(t *testing.T) { expected: []string{"apple"}, hasError: false, }, + { + name: "Empty entries are filtered out", + input: "apple,,banana, ,cherry", + expected: []string{"apple", "banana", "cherry"}, + hasError: false, + }, { name: "Non-string input", input: 123, @@ -346,6 +352,20 @@ func TestApplyCommonTransformation(t *testing.T) { expected: "HELLO", hasError: false, }, + { + name: "Trim transformation", + transformation: CommonTrim, + input: " hello ", + expected: "hello", + hasError: false, + }, + { + name: "Trim requires string", + transformation: CommonTrim, + input: 123, + expected: nil, + hasError: true, + }, { name: "Unknown transformation", transformation: "unknown", diff --git a/service/entityresolution/multi-strategy/transformation/constants.go b/service/entityresolution/multi-strategy/transformation/constants.go index c6b222fc8a..4bd3169813 100644 --- a/service/entityresolution/multi-strategy/transformation/constants.go +++ b/service/entityresolution/multi-strategy/transformation/constants.go @@ -14,6 +14,7 @@ const ( CommonString = "string" CommonLowercase = "lowercase" CommonUppercase = "uppercase" + CommonTrim = "trim" // Common parsing transformations CommonCSVToArray = "csv_to_array" @@ -21,7 +22,8 @@ const ( // SQL-specific transformation constants const ( - SQLPostgresArray = "postgres_array" + SQLPostgresArray = "postgres_array" + SQLPostgresObject = "postgres_object" ) // LDAP-specific transformation constants @@ -46,6 +48,7 @@ func GetCommonTransformations() []string { CommonString, CommonLowercase, CommonUppercase, + CommonTrim, } } @@ -53,6 +56,7 @@ func GetCommonTransformations() []string { func GetSQLTransformations() []string { return []string{ SQLPostgresArray, + SQLPostgresObject, } } diff --git a/service/entityresolution/multi-strategy/transformation/constants_test.go b/service/entityresolution/multi-strategy/transformation/constants_test.go index 3609239391..ebe35962ab 100644 --- a/service/entityresolution/multi-strategy/transformation/constants_test.go +++ b/service/entityresolution/multi-strategy/transformation/constants_test.go @@ -13,6 +13,7 @@ func TestGetCommonTransformations(t *testing.T) { CommonString, CommonLowercase, CommonUppercase, + CommonTrim, } if len(transformations) != len(expected) { @@ -155,6 +156,7 @@ func TestTransformationConstants(t *testing.T) { {"Common Uppercase", CommonUppercase, "uppercase"}, {"SQL Postgres Array", SQLPostgresArray, "postgres_array"}, + {"SQL Postgres Object", SQLPostgresObject, "postgres_object"}, {"LDAP DN to CN Array", LDAPDNToCNArray, "ldap_dn_to_cn_array"}, {"LDAP DN to CN", LDAPDNToCN, "ldap_dn_to_cn"}, {"LDAP Attribute Values", LDAPAttrValues, "ldap_attribute_values"}, diff --git a/service/entityresolution/multi-strategy/transformation/ldap.go b/service/entityresolution/multi-strategy/transformation/ldap.go index d3a627e49e..3e49c2074d 100644 --- a/service/entityresolution/multi-strategy/transformation/ldap.go +++ b/service/entityresolution/multi-strategy/transformation/ldap.go @@ -21,7 +21,9 @@ func ApplyLDAPTransformation(value interface{}, transformation string) (interfac } } -// ApplyLDAPDNToCNArray converts array of DNs to array of CNs +// ApplyLDAPDNToCNArray converts array of DNs to array of CNs. A single DN +// string is also accepted and returned as a one-element array to accommodate +// single-valued LDAP attributes. func ApplyLDAPDNToCNArray(value interface{}) (interface{}, error) { // Handle []interface{} arrays if arr, ok := value.([]interface{}); ok { @@ -49,7 +51,16 @@ func ApplyLDAPDNToCNArray(value interface{}) (interface{}, error) { return result, nil } - return nil, fmt.Errorf("ldap_dn_to_cn_array transformation requires array input, got %T", value) + // Handle single DN string + if str, ok := value.(string); ok { + cn := ExtractCNFromDN(str) + if cn == "" { + return []string{}, nil + } + return []string{cn}, nil + } + + return nil, fmt.Errorf("ldap_dn_to_cn_array transformation requires string or array input, got %T", value) } // ApplyLDAPDNToCN converts single DN to CN diff --git a/service/entityresolution/multi-strategy/transformation/ldap_test.go b/service/entityresolution/multi-strategy/transformation/ldap_test.go index 49027ec3f1..b7ad132207 100644 --- a/service/entityresolution/multi-strategy/transformation/ldap_test.go +++ b/service/entityresolution/multi-strategy/transformation/ldap_test.go @@ -142,8 +142,20 @@ func TestApplyLDAPDNToCNArray(t *testing.T) { hasError: false, }, { - name: "Non-array input", + name: "Single DN string", input: "CN=Single,OU=Users,DC=company,DC=com", + expected: []string{"Single"}, + hasError: false, + }, + { + name: "Single string without CN", + input: "OU=NoCommonName,DC=company,DC=com", + expected: []string{}, + hasError: false, + }, + { + name: "Unsupported input type", + input: 12345, expected: nil, hasError: true, }, diff --git a/service/entityresolution/multi-strategy/transformation/sql.go b/service/entityresolution/multi-strategy/transformation/sql.go index a13c4db780..be1058bb01 100644 --- a/service/entityresolution/multi-strategy/transformation/sql.go +++ b/service/entityresolution/multi-strategy/transformation/sql.go @@ -1,6 +1,7 @@ package transformation import ( + "encoding/json" "fmt" "strings" ) @@ -10,6 +11,8 @@ func ApplySQLTransformation(value interface{}, transformation string) (interface switch transformation { case SQLPostgresArray: return ApplyPostgresArray(value) + case SQLPostgresObject: + return ApplyPostgresObject(value) default: return nil, fmt.Errorf("unsupported SQL transformation: %s", transformation) } @@ -37,3 +40,37 @@ func ApplyPostgresArray(value interface{}) (interface{}, error) { } return parts, nil } + +// ApplyPostgresObject parses a PostgreSQL JSON/JSONB result into a map[string]any +// for use as a nested claim in entity resolution. Accepts string or []byte +// (as returned by the pgx driver for JSON/JSONB columns), or a map that is +// already decoded and returned as-is. +func ApplyPostgresObject(value any) (any, error) { + if value == nil { + return map[string]any{}, nil + } + + var raw []byte + switch v := value.(type) { + case map[string]any: + return v, nil + case string: + if v == "" { + return map[string]any{}, nil + } + raw = []byte(v) + case []byte: + if len(v) == 0 { + return map[string]any{}, nil + } + raw = v + default: + return nil, fmt.Errorf("postgres_object transformation requires string, []byte, or map input, got %T", value) + } + + result := make(map[string]any) + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("postgres_object transformation failed to parse JSON: %w", err) + } + return result, nil +} diff --git a/service/entityresolution/multi-strategy/transformation/sql_test.go b/service/entityresolution/multi-strategy/transformation/sql_test.go new file mode 100644 index 0000000000..e7c46da5ce --- /dev/null +++ b/service/entityresolution/multi-strategy/transformation/sql_test.go @@ -0,0 +1,118 @@ +package transformation + +import ( + "reflect" + "testing" +) + +func TestApplyPostgresObject(t *testing.T) { + tests := []struct { + name string + value any + expected map[string]any + expectError bool + }{ + { + name: "JSON string input", + value: `{"department":"Engineering","level":5,"active":true}`, + expected: map[string]any{ + "department": "Engineering", + "level": float64(5), + "active": true, + }, + }, + { + name: "JSONB []byte input", + value: []byte(`{"role":"admin","groups":["a","b"]}`), + expected: map[string]any{ + "role": "admin", + "groups": []any{"a", "b"}, + }, + }, + { + name: "Already decoded map[string]any passthrough", + value: map[string]any{ + "foo": "bar", + }, + expected: map[string]any{ + "foo": "bar", + }, + }, + { + name: "Nested JSON object", + value: `{"profile":{"name":"Alice","age":30},"tags":["x","y"]}`, + expected: map[string]any{ + "profile": map[string]any{ + "name": "Alice", + "age": float64(30), + }, + "tags": []any{"x", "y"}, + }, + }, + { + name: "Empty string returns empty map", + value: "", + expected: map[string]any{}, + }, + { + name: "Empty []byte returns empty map", + value: []byte{}, + expected: map[string]any{}, + }, + { + name: "nil returns empty map", + value: nil, + expected: map[string]any{}, + }, + { + name: "Invalid JSON returns error", + value: `{"not valid`, + expectError: true, + }, + { + name: "JSON array (not object) returns error", + value: `["a","b"]`, + expectError: true, + }, + { + name: "Unsupported input type returns error", + value: 12345, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ApplyPostgresObject(tt.value) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none, result=%v", result) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("result mismatch\n got: %#v\nwant: %#v", result, tt.expected) + } + }) + } +} + +func TestApplySQLTransformation_PostgresObject(t *testing.T) { + result, err := ApplySQLTransformation(`{"a":1}`, SQLPostgresObject) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + obj, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map[string]any, got %T", result) + } + if obj["a"] != float64(1) { + t.Errorf("expected a=1, got %v", obj["a"]) + } +}