Repository: genjidb/genji
Pull request #138 (2020-07-22 09:38:24)

New implementation of the Cast function
This PR fixes the behavior of the CAST function by adding a new implementation that defines correctly how types are cast.



Patch:
diff --git a/document/cast.go b/document/cast.go
new file mode 100644
index 00000000..2b136f25
--- /dev/null
+++ b/document/cast.go
@@ -0,0 +1,220 @@
+package document
+
+import (
+	"encoding/base64"
+	"fmt"
+	"strconv"
+	"time"
+)
+
+// CastAs casts v as the selected type when possible.
+func (v Value) CastAs(t ValueType) (Value, error) {
+	if v.Type == t {
+		return v, nil
+	}
+
+	// Null values always remain null.
+	if v.Type == NullValue {
+		return v, nil
+	}
+
+	switch t {
+	case BoolValue:
+		return v.CastAsBool()
+	case IntegerValue:
+		return v.CastAsInteger()
+	case DoubleValue:
+		return v.CastAsDouble()
+	case DurationValue:
+		return v.CastAsDuration()
+	case BlobValue:
+		return v.CastAsBlob()
+	case TextValue:
+		return v.CastAsText()
+	case ArrayValue:
+		return v.CastAsArray()
+	case DocumentValue:
+		return v.CastAsDocument()
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as %q", v.Type, t)
+}
+
+// CastAsBool casts according to the following rules:
+// Integer: true if truthy, otherwise false.
+// Text: uses strconv.Parsebool to determine the boolean value,
+// it fails if the text doesn't contain a valid boolean.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsBool() (Value, error) {
+	switch v.Type {
+	case BoolValue:
+		return v, nil
+	case IntegerValue:
+		return NewBoolValue(v.V.(int64) != 0), nil
+	case TextValue:
+		b, err := strconv.ParseBool(string(v.V.([]byte)))
+		if err != nil {
+			return Value{}, err
+		}
+		return NewBoolValue(b), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as bool", v.Type)
+}
+
+// CastAsInteger casts according to the following rules:
+// Bool: returns 1 if true, 0 if false.
+// Double: cuts off the decimal and remaining numbers.
+// Text: uses strconv.ParseFloat to determine the double value,
+// then casts it to an integer. It fails if the text doesn't
+// contain a valid float value.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsInteger() (Value, error) {
+	switch v.Type {
+	case IntegerValue:
+		return v, nil
+	case BoolValue:
+		if v.V.(bool) {
+			return NewIntegerValue(1), nil
+		}
+		return NewIntegerValue(0), nil
+	case DoubleValue:
+		return NewIntegerValue(int64(v.V.(float64))), nil
+	case TextValue:
+		f, err := strconv.ParseFloat(string(v.V.([]byte)), 64)
+		if err != nil {
+			return Value{}, err
+		}
+		return NewIntegerValue(int64(f)), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as integer", v.Type)
+}
+
+// CastAsDouble casts according to the following rules:
+// Integer: returns a double version of the integer.
+// Text: uses strconv.ParseFloat to determine the double value,
+// it fails if the text doesn't contain a valid float value.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsDouble() (Value, error) {
+	switch v.Type {
+	case DoubleValue:
+		return v, nil
+	case IntegerValue:
+		return NewDoubleValue(float64(v.V.(int64))), nil
+	case TextValue:
+		f, err := strconv.ParseFloat(string(v.V.([]byte)), 64)
+		if err != nil {
+			return Value{}, err
+		}
+		return NewDoubleValue(f), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as double", v.Type)
+}
+
+// CastAsDuration casts according to the following rules:
+// Text: decodes using time.ParseDuration, otherwise fails.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsDuration() (Value, error) {
+	if v.Type == DurationValue {
+		return v, nil
+	}
+
+	if v.Type == TextValue {
+		d, err := time.ParseDuration(string(v.V.([]byte)))
+		if err != nil {
+			return Value{}, err
+		}
+
+		return NewDurationValue(d), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as duration", v.Type)
+}
+
+// CastAsText returns a JSON representation of v.
+// If the representation is a string, it gets unquoted.
+func (v Value) CastAsText() (Value, error) {
+	if v.Type == TextValue {
+		return v, nil
+	}
+
+	d, err := v.MarshalJSON()
+	if err != nil {
+		return Value{}, err
+	}
+
+	s := string(d)
+
+	if v.Type == DurationValue || v.Type == BlobValue {
+		s, err = strconv.Unquote(s)
+		if err != nil {
+			return Value{}, err
+		}
+	}
+
+	return NewTextValue(s), nil
+}
+
+// CastAsBlob casts according to the following rules:
+// Text: decodes a base64 string, otherwise fails.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsBlob() (Value, error) {
+	if v.Type == BlobValue {
+		return v, nil
+	}
+
+	if v.Type == TextValue {
+		b, err := base64.StdEncoding.DecodeString(string(v.V.([]byte)))
+		if err != nil {
+			return Value{}, err
+		}
+
+		return NewBlobValue(b), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as blob", v.Type)
+}
+
+// CastAsArray casts according to the following rules:
+// Text: decodes a JSON array, otherwise fails.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsArray() (Value, error) {
+	if v.Type == ArrayValue {
+		return v, nil
+	}
+
+	if v.Type == TextValue {
+		var vb ValueBuffer
+		err := vb.UnmarshalJSON([]byte(string(v.V.([]byte))))
+		if err != nil {
+			return Value{}, err
+		}
+
+		return NewArrayValue(vb), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as array", v.Type)
+}
+
+// CastAsDocument casts according to the following rules:
+// Text: decodes a JSON object, otherwise fails.
+// Any other type is considered an invalid cast.
+func (v Value) CastAsDocument() (Value, error) {
+	if v.Type == DocumentValue {
+		return v, nil
+	}
+
+	if v.Type == TextValue {
+		var fb FieldBuffer
+		err := fb.UnmarshalJSON([]byte(string(v.V.([]byte))))
+		if err != nil {
+			return Value{}, err
+		}
+
+		return NewDocumentValue(&fb), nil
+	}
+
+	return Value{}, fmt.Errorf("cannot cast %q as document", v.Type)
+}
diff --git a/document/encoding/msgpack/codec.go b/document/encoding/msgpack/codec.go
index e1b63226..c070158c 100644
--- a/document/encoding/msgpack/codec.go
+++ b/document/encoding/msgpack/codec.go
@@ -100,31 +100,15 @@ func (e *Encoder) EncodeArray(a document.Array) error {
 func (e *Encoder) EncodeValue(v document.Value) error {
 	switch v.Type {
 	case document.DocumentValue:
-		d, err := v.ConvertToDocument()
-		if err != nil {
-			return err
-		}
-		return e.EncodeDocument(d)
+		return e.EncodeDocument(v.V.(document.Document))
 	case document.ArrayValue:
-		a, err := v.ConvertToArray()
-		if err != nil {
-			return err
-		}
-		return e.EncodeArray(a)
+		return e.EncodeArray(v.V.(document.Array))
 	case document.NullValue:
 		return e.enc.EncodeNil()
 	case document.TextValue:
-		s, err := v.ConvertToString()
-		if err != nil {
-			return err
-		}
-		return e.enc.EncodeString(s)
+		return e.enc.EncodeString(string(v.V.([]byte)))
 	case document.BlobValue:
-		b, err := v.ConvertToBytes()
-		if err != nil {
-			return err
-		}
-		return e.enc.EncodeBytes(b)
+		return e.enc.EncodeBytes(v.V.([]byte))
 	case document.BoolValue:
 		return e.enc.EncodeBool(v.V.(bool))
 	case document.IntegerValue:
diff --git a/document/json.go b/document/json.go
index f2bc780d..d860ae18 100644
--- a/document/json.go
+++ b/document/json.go
@@ -41,12 +41,18 @@ func (v Value) MarshalJSON() ([]byte, error) {
 			return nil, err
 		}
 		return jsonArray{a}.MarshalJSON()
-	case TextValue, BlobValue:
+	case TextValue:
 		s, err := v.ConvertToString()
 		if err != nil {
 			return nil, err
 		}
 		x = s
+	case DurationValue:
+		d, err := v.ConvertToDuration()
+		if err != nil {
+			return nil, err
+		}
+		x = d.String()
 	default:
 		x = v.V
 	}
diff --git a/document/value.go b/document/value.go
index f57c8857..827cc0a3 100644
--- a/document/value.go
+++ b/document/value.go
@@ -370,7 +370,7 @@ func (v Value) ConvertToFloat64() (float64, error) {
 }
 
 // ConvertToDocument returns a document from the value.
-// It only works if the type of v is DocumentValue.
+// It only works if the type of v is DocumentValue or NullValue.
 func (v Value) ConvertToDocument() (Document, error) {
 	if v.Type == NullValue {
 		return NewFieldBuffer(), nil
@@ -384,7 +384,7 @@ func (v Value) ConvertToDocument() (Document, error) {
 }
 
 // ConvertToArray returns an array from the value.
-// It only works if the type of v is ArrayValue.
+// It only works if the type of v is ArrayValue or NullValue.
 func (v Value) ConvertToArray() (Array, error) {
 	if v.Type == NullValue {
 		return NewValueBuffer(), nil
diff --git a/sql/parser/expr.go b/sql/parser/expr.go
index 04b70854..4d400c0e 100644
--- a/sql/parser/expr.go
+++ b/sql/parser/expr.go
@@ -520,5 +520,5 @@ func (p *Parser) parseCastExpression() (expr.Expr, error) {
 		return nil, newParseError(scanner.Tokstr(tok, lit), []string{")"}, pos)
 	}
 
-	return expr.Cast{Expr: e, ConvertTo: tp}, nil
+	return expr.Cast{Expr: e, CastAs: tp}, nil
 }
diff --git a/sql/query/expr/function.go b/sql/query/expr/function.go
index a524288c..4cf3b2a2 100644
--- a/sql/query/expr/function.go
+++ b/sql/query/expr/function.go
@@ -58,8 +58,8 @@ func (k PKFunc) String() string {
 
 // Cast represents the CAST expression.
 type Cast struct {
-	Expr      Expr
-	ConvertTo document.ValueType
+	Expr   Expr
+	CastAs document.ValueType
 }
 
 // Eval returns the primary key of the current document.
@@ -69,7 +69,7 @@ func (c Cast) Eval(ctx EvalStack) (document.Value, error) {
 		return v, err
 	}
 
-	return v.ConvertTo(c.ConvertTo)
+	return v.CastAs(c.CastAs)
 }
 
 // IsEqual compares this expression with the other expression and returns
@@ -84,7 +84,7 @@ func (c Cast) IsEqual(other Expr) bool {
 		return false
 	}
 
-	if c.ConvertTo != o.ConvertTo {
+	if c.CastAs != o.CastAs {
 		return false
 	}
 
@@ -96,5 +96,5 @@ func (c Cast) IsEqual(other Expr) bool {
 }
 
 func (c Cast) String() string {
-	return fmt.Sprintf("CAST(%v AS %v)", c.Expr, c.ConvertTo)
+	return fmt.Sprintf("CAST(%v AS %v)", c.Expr, c.CastAs)
 }


Test patch:
diff --git a/document/cast_test.go b/document/cast_test.go
new file mode 100644
index 00000000..cb253bc5
--- /dev/null
+++ b/document/cast_test.go
@@ -0,0 +1,161 @@
+package document
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestCastAs(t *testing.T) {
+	type test struct {
+		v, want Value
+		fails   bool
+	}
+
+	boolV := NewBoolValue(true)
+	integerV := NewIntegerValue(10)
+	durationV := NewDurationValue(3 * time.Second)
+	doubleV := NewDoubleValue(10.5)
+	textV := NewTextValue("foo")
+	blobV := NewBlobValue([]byte("abc"))
+	arrayV := NewArrayValue(NewValueBuffer().
+		Append(NewTextValue("bar")).
+		Append(integerV))
+	docV := NewDocumentValue(NewFieldBuffer().
+		Add("a", integerV).
+		Add("b", textV))
+
+	check := func(t *testing.T, targetType ValueType, tests []test) {
+		for _, test := range tests {
+			t.Run(test.v.String(), func(t *testing.T) {
+				got, err := test.v.CastAs(targetType)
+				if test.fails {
+					require.Error(t, err)
+				} else {
+					require.NoError(t, err)
+					require.Equal(t, test.want, got)
+				}
+			})
+		}
+	}
+
+	t.Run("bool", func(t *testing.T) {
+		check(t, BoolValue, []test{
+			{boolV, boolV, false},
+			{integerV, boolV, false},
+			{NewIntegerValue(0), NewBoolValue(false), false},
+			{durationV, Value{}, true},
+			{doubleV, Value{}, true},
+			{textV, Value{}, true},
+			{NewTextValue("true"), boolV, false},
+			{NewTextValue("false"), NewBoolValue(false), false},
+			{blobV, Value{}, true},
+			{arrayV, Value{}, true},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("integer", func(t *testing.T) {
+		check(t, IntegerValue, []test{
+			{boolV, NewIntegerValue(1), false},
+			{NewBoolValue(false), NewIntegerValue(0), false},
+			{integerV, integerV, false},
+			{durationV, Value{}, true},
+			{doubleV, integerV, false},
+			{textV, Value{}, true},
+			{NewTextValue("10"), integerV, false},
+			{NewTextValue("10.5"), integerV, false},
+			{blobV, Value{}, true},
+			{arrayV, Value{}, true},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("double", func(t *testing.T) {
+		check(t, DoubleValue, []test{
+			{boolV, Value{}, true},
+			{integerV, NewDoubleValue(10), false},
+			{durationV, Value{}, true},
+			{doubleV, doubleV, false},
+			{textV, Value{}, true},
+			{NewTextValue("10"), NewDoubleValue(10), false},
+			{NewTextValue("10.5"), doubleV, false},
+			{blobV, Value{}, true},
+			{arrayV, Value{}, true},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("duration", func(t *testing.T) {
+		check(t, DurationValue, []test{
+			{boolV, Value{}, true},
+			{integerV, Value{}, true},
+			{durationV, durationV, false},
+			{doubleV, Value{}, true},
+			{textV, Value{}, true},
+			{NewTextValue("3s"), durationV, false},
+			{NewTextValue("10.5"), Value{}, true},
+			{blobV, Value{}, true},
+			{arrayV, Value{}, true},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("text", func(t *testing.T) {
+		check(t, TextValue, []test{
+			{boolV, NewTextValue("true"), false},
+			{integerV, NewTextValue("10"), false},
+			{durationV, NewTextValue("3s"), false},
+			{doubleV, NewTextValue("10.5"), false},
+			{textV, textV, false},
+			{blobV, NewTextValue("YWJj"), false},
+			{arrayV, NewTextValue(`["bar", 10]`), false},
+			{docV,
+				NewTextValue(`{"a": 10, "b": "foo"}`),
+				false},
+		})
+	})
+
+	t.Run("blob", func(t *testing.T) {
+		check(t, BlobValue, []test{
+			{boolV, Value{}, true},
+			{integerV, Value{}, true},
+			{durationV, Value{}, true},
+			{doubleV, Value{}, true},
+			{NewTextValue("YWJj"), blobV, false},
+			{NewTextValue("   dww  "), Value{}, true},
+			{blobV, blobV, false},
+			{arrayV, Value{}, true},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("array", func(t *testing.T) {
+		check(t, ArrayValue, []test{
+			{boolV, Value{}, true},
+			{integerV, Value{}, true},
+			{durationV, Value{}, true},
+			{doubleV, Value{}, true},
+			{NewTextValue(`["bar", 10]`), arrayV, false},
+			{NewTextValue("abc"), Value{}, true},
+			{blobV, Value{}, true},
+			{arrayV, arrayV, false},
+			{docV, Value{}, true},
+		})
+	})
+
+	t.Run("document", func(t *testing.T) {
+		check(t, DocumentValue, []test{
+			{boolV, Value{}, true},
+			{integerV, Value{}, true},
+			{durationV, Value{}, true},
+			{doubleV, Value{}, true},
+			{NewTextValue(`{"a": 10, "b": "foo"}`), docV, false},
+			{NewTextValue("abc"), Value{}, true},
+			{blobV, Value{}, true},
+			{arrayV, Value{}, true},
+			{docV, docV, false},
+		})
+	})
+}
diff --git a/sql/parser/expr_test.go b/sql/parser/expr_test.go
index 7dcbb7c3..e7bce48b 100644
--- a/sql/parser/expr_test.go
+++ b/sql/parser/expr_test.go
@@ -163,7 +163,7 @@ func TestParserExpr(t *testing.T) {
 			), false},
 		{"with NULL", "age > NULL", expr.Gt(expr.FieldSelector([]string{"age"}), expr.NullValue()), false},
 		{"pk() function", "pk()", &expr.PKFunc{}, false},
-		{"CAST", "CAST(a.b.1.0 AS TEXT)", expr.Cast{Expr: expr.FieldSelector([]string{"a", "b", "1", "0"}), ConvertTo: document.TextValue}, false},
+		{"CAST", "CAST(a.b.1.0 AS TEXT)", expr.Cast{Expr: expr.FieldSelector([]string{"a", "b", "1", "0"}), CastAs: document.TextValue}, false},
 	}
 
 	for _, test := range tests {
diff --git a/sql/query/insert_test.go b/sql/query/insert_test.go
index 28efae6f..9475a18f 100644
--- a/sql/query/insert_test.go
+++ b/sql/query/insert_test.go
@@ -163,9 +163,9 @@ func TestInsertStmt(t *testing.T) {
 			"i": 10000000000,
 			"db": 21.21,
 			"b": true,
-			"du": 127,
-			"bb": "blobValue",
-			"byt": "bytesValue",
+			"du": "127ns",
+			"bb": "YmxvYlZhbHVl",
+			"byt": "Ynl0ZXNWYWx1ZQ==",
 			"t": "text",
 			"a": [1, "foo", true],
 			"d": {"foo": "bar"}