diff --git a/web/json_object_test.go b/web/json_object_test.go index 32ec9c0..1f70752 100644 --- a/web/json_object_test.go +++ b/web/json_object_test.go @@ -1259,3 +1259,448 @@ func TestConvertJSONObject_JSONPath(t *testing.T) { }) } } + +func TestConvertJSONObject_Int64Support(t *testing.T) { + testJSONOptions := defaultJSONOptions() + + tests := map[string]struct { + entity *framework.EntityConfig + objectJSON string + opts *jsonOptions + wantObject framework.Object + wantError error + }{ + "int64_from_json_number": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + }, + objectJSON: `{"id": 123456789012345}`, + opts: testJSONOptions, + wantObject: framework.Object{ + "id": int64(123456789012345), + }, + wantError: nil, + }, + "int64_from_string": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + }, + objectJSON: `{"id": "9223372036854775807"}`, // MaxInt64 + opts: testJSONOptions, + wantObject: framework.Object{ + "id": int64(9223372036854775807), + }, + wantError: nil, + }, + "int64_list_mixed_sources": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "ids", + Type: framework.AttributeTypeInt64, + List: true, + }, + }, + }, + objectJSON: `{"ids": [123, "456", 789]}`, + opts: testJSONOptions, + wantObject: framework.Object{ + "ids": []int64{123, 456, 789}, + }, + wantError: nil, + }, + "int64_precision_error": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + }, + objectJSON: `{"id": 9007199254740992}`, // 2^53 (unsafe) = 9.007199254740992e+15 + opts: testJSONOptions, + wantObject: nil, + wantError: errors.New("attribute id cannot be parsed into an int64 because the value 9.007199254740992e+15 is outside the safe integer range (±9.007199254740991e+15) and would lead to precision loss"), + }, + "int64_fractional_error": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + }, + objectJSON: `{"id": 123.45}`, + opts: testJSONOptions, + wantObject: nil, + wantError: errors.New("attribute id cannot be parsed into an int64 because the value is not an integer and has a fractional part"), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var object map[string]any + err := json.Unmarshal([]byte(tc.objectJSON), &object) + if err != nil { + t.Fatalf("Failed to unmarshal test input JSON object: %v", err) + } + + gotObject, gotError := convertJSONObject(tc.entity, object, tc.opts, nil) + + if tc.wantError != nil { + AssertDeepEqual(t, tc.wantError.Error(), gotError.Error()) + } else { + AssertDeepEqual(t, tc.wantError, gotError) + } + AssertDeepEqual(t, tc.wantObject, gotObject) + }) + } + + // Special test case: Direct int64 value (bypasses JSON unmarshaling) + t.Run("direct_int64_value", func(t *testing.T) { + entity := &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + } + + // Create object with actual int64 value (not from JSON) + object := map[string]any{ + "id": int64(123456789012345), // Direct int64, not from JSON unmarshaling + } + + gotObject, gotError := convertJSONObject(entity, object, testJSONOptions, nil) + + AssertDeepEqual(t, nil, gotError) + wantObject := framework.Object{ + "id": int64(123456789012345), + } + AssertDeepEqual(t, wantObject, gotObject) + }) +} + +func TestConvertJSONObject_AllDataTypes(t *testing.T) { + testJSONOptions := defaultJSONOptions() + + tests := map[string]struct { + entity *framework.EntityConfig + objectJSON string + opts *jsonOptions + wantObject framework.Object + wantError error + }{ + "mixed_attribute_types": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "stringAttr", + Type: framework.AttributeTypeString, + }, + { + ExternalId: "int64Attr", + Type: framework.AttributeTypeInt64, + }, + { + ExternalId: "boolAttr", + Type: framework.AttributeTypeBool, + }, + { + ExternalId: "doubleAttr", + Type: framework.AttributeTypeDouble, + }, + { + ExternalId: "dateTimeAttr", + Type: framework.AttributeTypeDateTime, + }, + }, + }, + objectJSON: `{ + "stringAttr": "test value", + "int64Attr": 123456789, + "boolAttr": true, + "doubleAttr": 123.45, + "dateTimeAttr": "2023-06-23T12:34:56Z" + }`, + opts: &jsonOptions{ + dateTimeFormats: []DateTimeFormatWithTimeZone{ + {Format: "2006-01-02T15:04:05Z", HasTimeZone: true}, + }, + }, + wantObject: framework.Object{ + "stringAttr": "test value", + "int64Attr": int64(123456789), + "boolAttr": true, + "doubleAttr": 123.45, + "dateTimeAttr": MustParseTime(t, "2023-06-23T12:34:56Z"), + }, + wantError: nil, + }, + "mixed_list_types": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "stringList", + Type: framework.AttributeTypeString, + List: true, + }, + { + ExternalId: "int64List", + Type: framework.AttributeTypeInt64, + List: true, + }, + { + ExternalId: "boolList", + Type: framework.AttributeTypeBool, + List: true, + }, + { + ExternalId: "doubleList", + Type: framework.AttributeTypeDouble, + List: true, + }, + }, + }, + objectJSON: `{ + "stringList": ["a", "b", "c"], + "int64List": [1, 2, 3], + "boolList": [true, false, true], + "doubleList": [1.1, 2.2, 3.3] + }`, + opts: testJSONOptions, + wantObject: framework.Object{ + "stringList": []string{"a", "b", "c"}, + "int64List": []int64{1, 2, 3}, + "boolList": []bool{true, false, true}, + "doubleList": []float64{1.1, 2.2, 3.3}, + }, + wantError: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var object map[string]any + err := json.Unmarshal([]byte(tc.objectJSON), &object) + if err != nil { + t.Fatalf("Failed to unmarshal test input JSON object: %v", err) + } + + gotObject, gotError := convertJSONObject(tc.entity, object, tc.opts, nil) + + AssertDeepEqual(t, tc.wantError, gotError) + AssertDeepEqual(t, tc.wantObject, gotObject) + }) + } +} + +func TestConvertJSONObject_ErrorHandling(t *testing.T) { + testJSONOptions := defaultJSONOptions() + + tests := map[string]struct { + entity *framework.EntityConfig + objectJSON string + opts *jsonOptions + wantObject framework.Object + wantError error + }{ + "invalid_attribute_type": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "stringAttr", + Type: framework.AttributeTypeString, + }, + }, + }, + objectJSON: `{"stringAttr": 123}`, // number instead of string + opts: testJSONOptions, + wantObject: nil, + wantError: errors.New("attribute stringAttr cannot be parsed into a string value"), + }, + "invalid_bool_conversion": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "boolAttr", + Type: framework.AttributeTypeBool, + }, + }, + }, + objectJSON: `{"boolAttr": "invalid"}`, + opts: testJSONOptions, + wantObject: nil, + wantError: errors.New("attribute boolAttr cannot be parsed into a bool value"), + }, + "child_entity_with_invalid_attribute": { + entity: &framework.EntityConfig{ + ExternalId: "test", + ChildEntities: []*framework.EntityConfig{ + { + ExternalId: "children", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "id", + Type: framework.AttributeTypeInt64, + }, + }, + }, + }, + }, + objectJSON: `{"children": [{"id": "invalid_int64"}]}`, + opts: testJSONOptions, + wantObject: nil, + wantError: errors.New("failed to parse objects for child entity children: attribute id cannot be parsed into an int64 value: strconv.ParseInt: parsing \"invalid_int64\": invalid syntax"), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var object map[string]any + err := json.Unmarshal([]byte(tc.objectJSON), &object) + if err != nil { + t.Fatalf("Failed to unmarshal test input JSON object: %v", err) + } + + gotObject, gotError := convertJSONObject(tc.entity, object, tc.opts, nil) + + if tc.wantError != nil { + AssertDeepEqual(t, tc.wantError.Error(), gotError.Error()) + } else { + AssertDeepEqual(t, tc.wantError, gotError) + } + AssertDeepEqual(t, tc.wantObject, gotObject) + }) + } +} + +func TestConvertJSONObject_EdgeCases(t *testing.T) { + testJSONOptions := defaultJSONOptions() + + tests := map[string]struct { + entity *framework.EntityConfig + objectJSON string + opts *jsonOptions + wantObject framework.Object + wantError error + }{ + "empty_lists": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "emptyStringList", + Type: framework.AttributeTypeString, + List: true, + }, + }, + ChildEntities: []*framework.EntityConfig{ + { + ExternalId: "emptyChildList", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "name", + Type: framework.AttributeTypeString, + }, + }, + }, + }, + }, + objectJSON: `{ + "emptyStringList": [], + "emptyChildList": [] + }`, + opts: testJSONOptions, + wantObject: framework.Object{ + "emptyStringList": []interface{}{}, + }, + wantError: nil, + }, + "null_values": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "nullString", + Type: framework.AttributeTypeString, + }, + { + ExternalId: "nullInt64", + Type: framework.AttributeTypeInt64, + }, + { + ExternalId: "validString", + Type: framework.AttributeTypeString, + }, + }, + }, + objectJSON: `{ + "nullString": null, + "nullInt64": null, + "validString": "test" + }`, + opts: testJSONOptions, + wantObject: framework.Object{ + "validString": "test", + }, + wantError: nil, + }, + "list_with_nulls": { + entity: &framework.EntityConfig{ + ExternalId: "test", + Attributes: []*framework.AttributeConfig{ + { + ExternalId: "mixedList", + Type: framework.AttributeTypeString, + List: true, + }, + }, + }, + objectJSON: `{"mixedList": ["a", null, "b", null, "c"]}`, + opts: testJSONOptions, + wantObject: framework.Object{ + "mixedList": []string{"a", "b", "c"}, // nulls filtered out + }, + wantError: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var object map[string]any + err := json.Unmarshal([]byte(tc.objectJSON), &object) + if err != nil { + t.Fatalf("Failed to unmarshal test input JSON object: %v", err) + } + + gotObject, gotError := convertJSONObject(tc.entity, object, tc.opts, nil) + + AssertDeepEqual(t, tc.wantError, gotError) + AssertDeepEqual(t, tc.wantObject, gotObject) + }) + } +} diff --git a/web/json_value.go b/web/json_value.go index ff846c3..48804aa 100644 --- a/web/json_value.go +++ b/web/json_value.go @@ -112,11 +112,18 @@ func convertJSONAttributeValue(attribute *framework.AttributeConfig, value any, return t, nil case framework.AttributeTypeDouble: - v, ok := value.(float64) - if !ok { - return nil, fmt.Errorf("attribute %s cannot be parsed into a float64 value", attribute.ExternalId) + switch v := value.(type) { + case float64: + return v, nil + case string: + parsed, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil, fmt.Errorf("attribute %s cannot be parsed into a float64 value: %w", attribute.ExternalId, err) + } + return parsed, nil + default: + return nil, fmt.Errorf("attribute %s cannot be parsed into a float64 due to invalid type: %T", attribute.ExternalId, v) } - return v, nil case framework.AttributeTypeDuration: switch v := value.(type) { @@ -131,12 +138,29 @@ func convertJSONAttributeValue(attribute *framework.AttributeConfig, value any, } case framework.AttributeTypeInt64: - // All numbers are unmarshalled into float64. Convert into int64. - v, ok := value.(float64) - if !ok { - return nil, fmt.Errorf("attribute %s cannot be parsed into an int64 value", attribute.ExternalId) + switch v := value.(type) { + case int64: + return v, nil + case float64: + // Check if the float64 value is within the safe integer range for accurate conversion + const maxSafeInteger = 1<<53 - 1 // 2^53 - 1 + if v > maxSafeInteger || v < -maxSafeInteger { + return nil, fmt.Errorf("attribute %s cannot be parsed into an int64 because the value %g is outside the safe integer range (±%g) and would lead to precision loss", attribute.ExternalId, v, float64(maxSafeInteger)) + } + // Ensure the value is actually an integer (no fractional part) + if float64(int64(v)) != v { + return nil, fmt.Errorf("attribute %s cannot be parsed into an int64 because the value is not an integer and has a fractional part", attribute.ExternalId) + } + return int64(v), nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("attribute %s cannot be parsed into an int64 value: %w", attribute.ExternalId, err) + } + return parsed, nil + default: + return nil, fmt.Errorf("attribute %s cannot be parsed into an int64 due to invalid type: %T", attribute.ExternalId, v) } - return int64(v), nil case framework.AttributeTypeString: v, ok := value.(string) diff --git a/web/json_value_test.go b/web/json_value_test.go index 5af29c2..e556571 100644 --- a/web/json_value_test.go +++ b/web/json_value_test.go @@ -17,6 +17,7 @@ package web import ( "encoding/json" "errors" + "math" "testing" "time" @@ -451,6 +452,62 @@ func TestConvertJSONAttributeValue(t *testing.T) { valueJSON: `[12, 34, 56]`, wantValue: []float64{12, 34, 56}, }, + "double_from_string": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"123.456"`, + wantValue: float64(123.456), + }, + "double_from_string_integer": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"123"`, + wantValue: float64(123), + }, + "double_from_string_scientific": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"1.23e-4"`, + wantValue: float64(0.000123), + }, + "double_from_string_infinity": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"Inf"`, + wantValue: math.Inf(1), + }, + "double_from_string_negative_infinity": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"-Inf"`, + wantValue: math.Inf(-1), + }, + "double_from_string_invalid": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `"not_a_number"`, + wantError: errors.New("attribute a cannot be parsed into a float64 value: strconv.ParseFloat: parsing \"not_a_number\": invalid syntax"), + }, + "double_from_unsupported_type": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeDouble, + }, + valueJSON: `true`, + wantError: errors.New("attribute a cannot be parsed into a float64 due to invalid type: bool"), + }, "duration_iso8601_valid": { attribute: &framework.AttributeConfig{ ExternalId: "a", @@ -623,6 +680,78 @@ func TestConvertJSONAttributeValue(t *testing.T) { valueJSON: `[12, 34, 56]`, wantValue: []int64{12, 34, 56}, }, + "int64_from_string": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `"123456789012345"`, + wantValue: int64(123456789012345), + }, + "int64_from_string_max_value": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `"9223372036854775807"`, // MaxInt64 + wantValue: int64(9223372036854775807), + }, + "int64_from_string_min_value": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `"-9223372036854775808"`, // MinInt64 + wantValue: int64(-9223372036854775808), + }, + "int64_from_string_invalid": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `"not_a_number"`, + wantError: errors.New("attribute a cannot be parsed into an int64 value: strconv.ParseInt: parsing \"not_a_number\": invalid syntax"), + }, + "int64_from_string_overflow": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `"9223372036854775808"`, // MaxInt64 + 1 + wantError: errors.New("attribute a cannot be parsed into an int64 value: strconv.ParseInt: parsing \"9223372036854775808\": value out of range"), + }, + "int64_from_float64_safe_range": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `9007199254740991`, // 2^53 - 1 (max safe integer) + wantValue: int64(9007199254740991), + }, + "int64_from_float64_unsafe_range": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `9007199254740992`, // 2^53 (unsafe) = 9.007199254740992e+15 + wantError: errors.New("attribute a cannot be parsed into an int64 because the value 9.007199254740992e+15 is outside the safe integer range (±9.007199254740991e+15) and would lead to precision loss"), + }, + "int64_from_float64_with_fractional": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `123.45`, + wantError: errors.New("attribute a cannot be parsed into an int64 because the value is not an integer and has a fractional part"), + }, + "int64_from_unsupported_type": { + attribute: &framework.AttributeConfig{ + ExternalId: "a", + Type: framework.AttributeTypeInt64, + }, + valueJSON: `true`, + wantError: errors.New("attribute a cannot be parsed into an int64 due to invalid type: bool"), + }, "string": { attribute: &framework.AttributeConfig{ ExternalId: "a",