Skip to content

Commit

Permalink
GH-44 implement driver.Valuer and sql.Scanner for Amount and Currency (
Browse files Browse the repository at this point in the history
…#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 <[email protected]>
  • Loading branch information
davidalpert and Rhymond authored Apr 26, 2024
1 parent 75eda0b commit 89bfd6b
Show file tree
Hide file tree
Showing 3 changed files with 246 additions and 2 deletions.
89 changes: 89 additions & 0 deletions db.go
Original file line number Diff line number Diff line change
@@ -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
}
155 changes: 155 additions & 0 deletions db_test.go
Original file line number Diff line number Diff line change
@@ -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 <nil>")
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)
}
})
}
}
4 changes: 2 additions & 2 deletions money.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 89bfd6b

Please sign in to comment.