From 89bfd6b376f2e1c6047cd2141f39c1cc6a4d86c0 Mon Sep 17 00:00:00 2001 From: David Alpert Date: Fri, 26 Apr 2024 05:49:11 -0500 Subject: [PATCH] GH-44 implement driver.Valuer and sql.Scanner for Amount and Currency (#99) * GH-44 implement driver.Valuer and sql.Scanner for money.Currency This allows any Golang ORM which supports the sql.Scanner to serialize (via sql.Driver) and deserialize (via sql.Scanner) a money.Currency instance. money.Amount is now a type alias to int64 which is already supported by sql.Scanner as one of the core built-in data types * GH-44 implement driver.Value and sql.Scanner for money.Money Money's Value() function enables compatible sql drivers to serialize a money.Money instance to a single comma-delimited string value of "amount,currency_code" Money's Scan() function assumes that it receives a single column where the src value is a comma- delimited string in the format "amount,currency_code" While the storage format is up to the client when the amount and currency are stored separately a compatible scanner value can be created in SQLite like this: SELECT amount || "," || currency as 'amount' It is left to the client to decide to use Money's Valuer implementation with a db annotation on a property of type Money or else to store the Amount and Currency values as two separate columns and return them as a single joined string field. * GH-44 fix an edge case strings.Split(src,,) will return a slice with length 2 even if one of the strings is empty * fix: money.value tests * refactor out the currency separator and make it customizable clients can set money.DBMoneyValueSeparator to determine which separator (e.g. "," "|" ";" ":" "AS" etc) to use when creating a single driver.Value object to represent a money.Money instance as a single string database field. this allows the money package to support string values such as 10@USD 20;CAD 30|IRD 40 in GBP etc --------- Co-authored-by: Raymond --- db.go | 89 ++++++++++++++++++++++++++++++ db_test.go | 155 +++++++++++++++++++++++++++++++++++++++++++++++++++++ money.go | 4 +- 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 db.go create mode 100644 db_test.go diff --git a/db.go b/db.go new file mode 100644 index 0000000..4f8d509 --- /dev/null +++ b/db.go @@ -0,0 +1,89 @@ +package money + +import ( + "database/sql/driver" + "fmt" + "strconv" + "strings" +) + +var ( + // DBMoneyValueSeparator is used to join together the Amount and Currency components of money.Money instances + // allowing them to be stored as strings (via the driver.Valuer interface) and unmarshalled as strings (via + // the sql.Scanner interface); set this value to use a different separator. + DBMoneyValueSeparator = DefaultDBMoneyValueSeparator +) + +const ( + // DefaultDBMoneyValueSeparator is the default value for DBMoneyValueSeparator; can be used to reset the + // active separator value + DefaultDBMoneyValueSeparator = "|" +) + +// Value implements driver.Valuer to serialise a Money instance into a delimited string using the DBMoneyValueSeparator +// for example: "amount|currency_code" +func (m *Money) Value() (driver.Value, error) { + return fmt.Sprintf("%d%s%s", m.amount, DBMoneyValueSeparator, m.Currency().Code), nil +} + +// Scan implements sql.Scanner to deserialize a Money instance from a DBMoneyValueSeparator-separated string +// for example: "amount|currency_code" +func (m *Money) Scan(src interface{}) error { + var amount Amount + currency := &Currency{} + + // let's support string and int64 + switch src.(type) { + case string: + parts := strings.Split(src.(string), DBMoneyValueSeparator) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("%#v is not valid to scan into Money; update your query to return a money.DBMoneyValueSeparator-separated pair of \"amount%scurrency_code\"", src.(string), DBMoneyValueSeparator) + } + + if a, err := strconv.ParseInt(parts[0], 10, 64); err == nil { + amount = a + } else { + return fmt.Errorf("scanning %#v into an Amount: %v", parts[0], err) + } + + if err := currency.Scan(parts[1]); err != nil { + return fmt.Errorf("scanning %#v into a Currency: %v", parts[1], err) + } + default: + return fmt.Errorf("don't know how to scan %T into Money; update your query to return a money.DBMoneyValueSeparator-separated pair of \"amount%scurrency_code\"", src, DBMoneyValueSeparator) + } + + // allocate new Money with the scanned amount and currency + *m = Money{ + amount: amount, + currency: currency, + } + + return nil +} + +// Value implements driver.Valuer to serialize a Currency code into a string for saving to a database +func (c Currency) Value() (driver.Value, error) { + return c.Code, nil +} + +// Scan implements sql.Scanner to deserialize a Currency from a string value read from a database +func (c *Currency) Scan(src interface{}) error { + var val *Currency + // let's support string only + switch src.(type) { + case string: + val = GetCurrency(src.(string)) + default: + return fmt.Errorf("%T is not a supported type for a Currency (store the Currency.Code value as a string only)", src) + } + + if val == nil { + return fmt.Errorf("GetCurrency(%#v) returned nil", src) + } + + // copy the value + *c = *val + + return nil +} diff --git a/db_test.go b/db_test.go new file mode 100644 index 0000000..73c7cea --- /dev/null +++ b/db_test.go @@ -0,0 +1,155 @@ +package money + +import ( + "database/sql/driver" + "fmt" + "reflect" + "testing" +) + +func TestMoney_Value(t *testing.T) { + tests := []struct { + have *Money + separator string + want string + wantErr bool + }{ + { + have: New(10, CAD), + separator: "|", + want: "10|CAD", + }, + { + have: New(-10, USD), + separator: "+-+", + want: "-10+-+USD", + }, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%#v", tt.have), func(t *testing.T) { + want := driver.Value(tt.want) + DBMoneyValueSeparator = tt.separator + got, err := tt.have.Value() + if err != nil { + t.Errorf("Value() error = %v", err) + return + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Value() got = %v, want %v", got, want) + } + }) + } +} + +func TestMoney_Scan(t *testing.T) { + tests := []struct { + src interface{} + separator string + want *Money + wantErr bool + }{ + { + src: "10|CAD", + want: New(10, CAD), + }, + { + src: "20|USD", + want: New(20, USD), + }, + { + src: "30000,IDR", + separator: ",", + want: New(30000, IDR), + }, + { + src: "10|", + wantErr: true, + }, + { + src: "|SAR", + wantErr: true, + }, + { + src: "10", + wantErr: true, + }, + { + src: "USD", + wantErr: true, + }, + { + src: "USD|10", + wantErr: true, + }, + { + src: "", + wantErr: true, + }, + { + src: "a|b|c", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%#v", tt.src), func(t *testing.T) { + if tt.separator != "" { + DBMoneyValueSeparator = tt.separator + } else { + DBMoneyValueSeparator = DefaultDBMoneyValueSeparator + } + got := &Money{} + if err := got.Scan(tt.src); (err != nil) != tt.wantErr { + t.Errorf("Scan() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got == nil { + t.Errorf("money.Scan() result was ") + return + } + eq, err := tt.want.Equals(got) + if err != nil { + t.Errorf(err.Error()) + } + if !eq { + t.Errorf("Value() got = %s %s, want %s %s", got.Display(), got.Currency().Code, tt.want.Display(), tt.want.Currency().Code) + } + }) + } +} + +func TestCurrency_Value(t *testing.T) { + for code, cc := range currencies { + t.Run(code, func(t *testing.T) { + want := driver.Value(code) + + got, err := cc.Value() + if err != nil { + t.Errorf("Value() error = %v", err) + return + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Value() got = %v, want %v", got, want) + } + }) + } +} + +func TestCurrency_Scan(t *testing.T) { + for code, want := range currencies { + t.Run(code, func(t *testing.T) { + src := interface{}(code) + + got := &Currency{} + err := got.Scan(src) + if err != nil { + t.Errorf("Scan() error = %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Scan() got %#v, want %#v", got, want) + } + }) + } +} diff --git a/money.go b/money.go index c359f16..51c623f 100644 --- a/money.go +++ b/money.go @@ -74,8 +74,8 @@ type Amount = int64 // Money represents monetary value information, stores // currency and amount value. type Money struct { - amount Amount - currency *Currency + amount Amount `db:"amount"` + currency *Currency `db:"currency"` } // New creates and returns new instance of Money.