Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion gcvctor/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@
// [cloud.google.com/go/spanner/apiv1/spannerpb.TypeAnnotationCode_PG_JSONB],
// [cloud.google.com/go/spanner/apiv1/spannerpb.TypeAnnotationCode_PG_OID]).
//
// NUMERIC wire strings: [NumericValue] and [PGNumericValue] store Spanner-canonical decimals.
// NUMERIC wire strings: [NumericValue] and [PGNumericValue] store Spanner-canonical decimals
// at the GoogleSQL 9-fractional-digit scale (silently rounding); [PGNumericValueExact]
// renders the exact decimal for the wider PG-dialect numeric value space, erroring on
// non-terminating rationals.
// [StringBasedValueFromCode] does not normalize; callers that build NUMERIC cells by hand must
// supply the same wire form Spanner returns (see that helper's doc). The [github.com/apstndb/spanvalue]
// formatters treat NUMERIC string payloads as authoritative and do not parse them again.
Expand Down
53 changes: 52 additions & 1 deletion gcvctor/gcvctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ var (
ErrNilElementType = errors.New("gcvctor: nil array element type")
// ErrNilFieldType is returned by [StructValueOf] when a field's Type is nil.
ErrNilFieldType = errors.New("gcvctor: nil struct field type")
// ErrNilNumeric is returned by [NumericValueChecked] and [PGNumericValueChecked] when v is nil.
// ErrNilNumeric is returned by [NumericValueChecked], [PGNumericValueChecked],
// and [PGNumericValueExact] when v is nil.
ErrNilNumeric = errors.New("gcvctor: nil numeric input")
// ErrInexactNumeric is returned by [PGNumericValueExact] when v has no
// finite decimal expansion and therefore cannot be rendered exactly.
ErrInexactNumeric = errors.New("gcvctor: numeric input has no finite decimal expansion")
// ErrInvalidJSON is returned by [JSONStringValue] when v is not syntactically valid JSON.
ErrInvalidJSON = errors.New("gcvctor: invalid JSON input")
)
Expand Down Expand Up @@ -324,6 +328,53 @@ func PGNumericValue(v *big.Rat) spanner.GenericColumnValue {
return StringBasedValueOf(typector.PGNumeric(), spanner.NumericString(v))
}

// PGNumericValueExact returns a non-null PostgreSQL-dialect NUMERIC
// GenericColumnValue ([sppb.TypeAnnotationCode_PG_NUMERIC]) whose wire string
// is the exact decimal rendering of v. Unlike [PGNumericValue], which formats
// with the GoogleSQL-scale [cloud.google.com/go/spanner.NumericString]
// (9 fractional digits, silently rounding), this constructor refuses to lose
// precision for the wider PostgreSQL-dialect numeric value space: a rational
// without a finite decimal expansion (a reduced denominator with prime
// factors other than 2 and 5, such as 1/3) returns [ErrInexactNumeric], and
// nil returns [ErrNilNumeric]. Callers holding exact decimal wire text can
// use [StringBasedValueOf] or [PGNumericFromNullable] instead.
func PGNumericValueExact(v *big.Rat) (spanner.GenericColumnValue, error) {
if v == nil {
return spanner.GenericColumnValue{}, ErrNilNumeric
}
scale, ok := finiteDecimalScale(v)
if !ok {
return spanner.GenericColumnValue{}, fmt.Errorf("%w: %s", ErrInexactNumeric, v.RatString())
}
return StringBasedValueOf(typector.PGNumeric(), v.FloatString(scale)), nil
}

// finiteDecimalScale reports the smallest scale that renders v exactly in
// decimal, or ok == false when v has no finite decimal expansion. big.Rat is
// always normalized, so the reduced denominator must factor into 2^a * 5^b.
func finiteDecimalScale(v *big.Rat) (int, bool) {
d := new(big.Int).Set(v.Denom())
twos := 0
for d.Bit(0) == 0 {
d.Rsh(d, 1)
twos++
}
five := big.NewInt(5)
fives := 0
for {
q, r := new(big.Int).QuoRem(d, five, new(big.Int))
if r.Sign() != 0 {
break
}
d = q
fives++
}
Comment thread
apstndb marked this conversation as resolved.
if d.Cmp(big.NewInt(1)) != 0 {
return 0, false
}
return max(twos, fives), true
}

// PGNumericValueChecked returns a non-null PostgreSQL-dialect NUMERIC GenericColumnValue
// ([sppb.TypeAnnotationCode_PG_NUMERIC]).
// A nil v returns [ErrNilNumeric].
Expand Down
79 changes: 79 additions & 0 deletions gcvctor/gcvctor_pg_exact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package gcvctor_test

import (
"errors"
"math/big"
"testing"

"cloud.google.com/go/spanner"
"github.com/apstndb/spantype/typector"
"github.com/apstndb/spanvalue/gcvctor"
"github.com/google/go-cmp/cmp"
"google.golang.org/protobuf/testing/protocmp"
"google.golang.org/protobuf/types/known/structpb"
)

func TestPGNumericValueExact(t *testing.T) {
t.Parallel()

wantWire := func(wire string) spanner.GenericColumnValue {
return spanner.GenericColumnValue{
Type: typector.PGNumeric(),
Value: structpb.NewStringValue(wire),
}
}

tests := []struct {
name string
in *big.Rat
want string
}{
{"integer", big.NewRat(42, 1), "42"},
{"negative half", big.NewRat(-1, 2), "-0.5"},
{"power of two denominator", big.NewRat(1, 8), "0.125"},
{"mixed 2s and 5s", big.NewRat(1, 20), "0.05"},
// 17 fractional digits: beyond the 9-digit NumericString scale that
// PGNumericValue would silently round to.
{"beyond GoogleSQL scale", new(big.Rat).SetFrac64(1, 1e17), "0.00000000000000001"},
{"zero", big.NewRat(0, 1), "0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := gcvctor.PGNumericValueExact(tt.in)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(wantWire(tt.want), got, protocmp.Transform()); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}

t.Run("non-terminating", func(t *testing.T) {
t.Parallel()
if _, err := gcvctor.PGNumericValueExact(big.NewRat(1, 3)); !errors.Is(err, gcvctor.ErrInexactNumeric) {
t.Errorf("error = %v, want ErrInexactNumeric", err)
}
// 1/6 reduces to a denominator with factor 3.
if _, err := gcvctor.PGNumericValueExact(big.NewRat(1, 6)); !errors.Is(err, gcvctor.ErrInexactNumeric) {
t.Errorf("error = %v, want ErrInexactNumeric", err)
}
})

t.Run("nil", func(t *testing.T) {
t.Parallel()
if _, err := gcvctor.PGNumericValueExact(nil); !errors.Is(err, gcvctor.ErrNilNumeric) {
t.Errorf("error = %v, want ErrNilNumeric", err)
}
})

t.Run("contrast with PGNumericValue rounding", func(t *testing.T) {
t.Parallel()
v := new(big.Rat).SetFrac64(1, 1e17)
rounded := gcvctor.PGNumericValue(v)
if got := rounded.Value.GetStringValue(); got != "0.000000000" {
t.Errorf("PGNumericValue wire = %q, want the 9-digit rounding this constructor exists to avoid", got)
}
})
}
10 changes: 10 additions & 0 deletions gcvctor/must.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,13 @@ func MustPGJSONBValue(v any) spanner.GenericColumnValue {
}
return gcv
}

// MustPGNumericValueExact is [PGNumericValueExact] panicking on error, for
// schema-known fixture data; see the package doc's fixture guidance.
func MustPGNumericValueExact(v *big.Rat) spanner.GenericColumnValue {
gcv, err := PGNumericValueExact(v)
if err != nil {
panic(err)
}
return gcv
}
Loading