Skip to content

Commit 7e0e1c7

Browse files
committed
feat: add decimal loss support for Convert
TODO: add it to ParseOption later
1 parent e02a862 commit 7e0e1c7

File tree

4 files changed

+113
-2
lines changed

4 files changed

+113
-2
lines changed

asserters_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,9 @@ func TestErrorMessage(t *testing.T) {
106106
requireErrorIs(t, err, safecast.ErrConversionIssue)
107107
requireErrorIs(t, err, safecast.ErrExceedMinimumValue)
108108
requireErrorContains(t, err, "than -128 (int8)")
109+
110+
_, err = safecast.Convert[int8](3.14, safecast.WithDecimalLossReport())
111+
requireErrorIs(t, err, safecast.ErrConversionIssue)
112+
requireErrorIs(t, err, safecast.ErrDecimalLoss)
113+
requireErrorContains(t, err, "decimal loss")
109114
}

conversion.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func RequireConvert[NumOut Number, NumIn Number](t TestingT, orig NumIn) (conver
5555
// # General errors wrapped on conversion failure:
5656
//
5757
// - [ErrConversionIssue] is always wrapped in the returned error when [Convert] fails (example "abc", -1, or 1000 to uint8).
58-
func Convert[NumOut Number, NumIn Number](orig NumIn) (NumOut, error) {
58+
func Convert[NumOut Number, NumIn Number](orig NumIn, opts ...ConvertOption) (NumOut, error) {
5959
converted := NumOut(orig)
6060
if isFloat64[NumIn]() {
6161
floatOrig := float64(orig)
@@ -70,6 +70,8 @@ func Convert[NumOut Number, NumIn Number](orig NumIn) (NumOut, error) {
7070
}
7171
}
7272

73+
config := newConvertOptions(opts...)
74+
7375
if isFloat64[NumOut]() {
7476
// float64 cannot overflow, so we don't have to worry about it
7577
return converted, nil
@@ -103,6 +105,15 @@ func Convert[NumOut Number, NumIn Number](orig NumIn) (NumOut, error) {
103105
return converted, getRangeError[NumOut](orig)
104106
}
105107

108+
if config.reportDecimalLoss && isFloat[NumIn]() && !isFloat[NumOut]() {
109+
if orig != cast {
110+
return converted, errorHelper[NumOut]{
111+
value: orig,
112+
err: ErrDecimalLoss,
113+
}
114+
}
115+
}
116+
106117
return converted, nil
107118
}
108119

@@ -117,3 +128,36 @@ func getRangeError[NumOut Number, NumIn Number](value NumIn) error {
117128
err: err,
118129
}
119130
}
131+
132+
type convertConfig struct {
133+
reportDecimalLoss bool
134+
}
135+
136+
// ConvertOption is a function type used to set options for the [Convert] function.
137+
type ConvertOption func(*convertConfig)
138+
139+
func newConvertOptions(opts ...ConvertOption) *convertConfig {
140+
po := &convertConfig{
141+
reportDecimalLoss: false,
142+
}
143+
144+
for _, opt := range opts {
145+
opt(po)
146+
}
147+
return po
148+
}
149+
150+
// WithDecimalLossReport is a [ConvertOption] that enables reporting of decimal loss
151+
// when converting from a floating-point type to an integer type.
152+
//
153+
// When this option is used, if the conversion results in loss of decimal information,
154+
// the returned error will wrap [ErrDecimalLoss].
155+
//
156+
// Example:
157+
//
158+
// value, err := Convert[int](3.14, WithDecimalLossReport())
159+
func WithDecimalLossReport() ConvertOption {
160+
return func(cfg *convertConfig) {
161+
cfg.reportDecimalLoss = true
162+
}
163+
}

conversion_test.go

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
type MapTest[TypeInput safecast.Number, TypeOutput safecast.Number] struct {
2020
Input TypeInput
21+
Options []safecast.ConvertOption
2122
ExpectedOutput TypeOutput
2223
ExpectedError error
2324
ErrorContains string
@@ -36,7 +37,7 @@ func (mt MapTest[I, O]) Run(t *testing.T) {
3637
}
3738
}(t)
3839

39-
out, err := safecast.Convert[O](mt.Input)
40+
out, err := safecast.Convert[O](mt.Input, mt.Options...)
4041
if mt.ExpectedError != nil {
4142
requireErrorIs(t, err, safecast.ErrConversionIssue)
4243
requireErrorIs(t, err, mt.ExpectedError)
@@ -432,6 +433,46 @@ func TestConvert(t *testing.T) {
432433
})
433434
}
434435
})
436+
437+
t.Run("float to int rounding", func(t *testing.T) {
438+
t.Run("without decimal loss", func(t *testing.T) {
439+
for name, tt := range map[string]TestRunner{
440+
"float32": MapTest[float32, int]{
441+
Input: 3.14,
442+
ExpectedOutput: 3,
443+
},
444+
"float64": MapTest[float64, int]{
445+
Input: 3.14,
446+
ExpectedOutput: 3,
447+
},
448+
} {
449+
t.Run(name, func(t *testing.T) {
450+
tt.Run(t)
451+
})
452+
}
453+
})
454+
455+
t.Run("with decimal loss", func(t *testing.T) {
456+
for name, tt := range map[string]TestRunner{
457+
"float32": MapTest[float32, int]{
458+
Input: 3.14,
459+
Options: []safecast.ConvertOption{safecast.WithDecimalLossReport()},
460+
ExpectedOutput: 3,
461+
ExpectedError: safecast.ErrDecimalLoss,
462+
},
463+
"float64": MapTest[float64, int]{
464+
Input: 3.14,
465+
Options: []safecast.ConvertOption{safecast.WithDecimalLossReport()},
466+
ExpectedOutput: 3,
467+
ExpectedError: safecast.ErrDecimalLoss,
468+
},
469+
} {
470+
t.Run(name, func(t *testing.T) {
471+
tt.Run(t)
472+
})
473+
}
474+
})
475+
})
435476
}
436477

437478
type MapMustConvertTest[TypeInput safecast.Number, TypeOutput safecast.Number] struct {
@@ -602,3 +643,17 @@ func ExampleRequireConvert_failure() {
602643
// --- FAIL:
603644
// conversion issue: -1 (int) is less than 0 (uint8): minimum value for this type exceeded
604645
}
646+
647+
func ExampleWithDecimalLossReport() {
648+
// By default, converting from float to int does not report decimal loss
649+
val1, err1 := safecast.Convert[int](3.14)
650+
fmt.Println(val1, err1)
651+
652+
// Using the WithDecimalLossReport option, decimal loss is reported as an error
653+
val2, err2 := safecast.Convert[int](3.14, safecast.WithDecimalLossReport())
654+
fmt.Println(val2, err2)
655+
656+
// Output:
657+
// 3 <nil>
658+
// 3 conversion issue: decimal loss during conversion
659+
}

errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ var ErrUnsupportedConversion = errors.New("unsupported type")
4848
// [ErrConversionIssue] is also wrapped when this error is returned.
4949
var ErrStringConversion = errors.New("cannot convert from string")
5050

51+
// ErrDecimalLoss is an error for when decimal loss occurs during conversion.
52+
//
53+
// Examples include converting 3.14 to int.
54+
//
55+
// [ErrConversionIssue] is also wrapped when this error is returned.
56+
var ErrDecimalLoss = errors.New("decimal loss during conversion")
57+
5158
// errorHelper is a helper struct for error messages
5259
// It is used to wrap other errors, and provides additional information
5360
type errorHelper[NumOut Number] struct {

0 commit comments

Comments
 (0)