Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(bigquery): support nullable params and geography params #4225

Merged
merged 6 commits into from Jun 17, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
99 changes: 91 additions & 8 deletions bigquery/params.go
Expand Up @@ -76,6 +76,7 @@ var (
timestampParamType = &bq.QueryParameterType{Type: "TIMESTAMP"}
numericParamType = &bq.QueryParameterType{Type: "NUMERIC"}
bigNumericParamType = &bq.QueryParameterType{Type: "BIGNUMERIC"}
geographyParamType = &bq.QueryParameterType{Type: "GEOGRAPHY"}
)

var (
Expand Down Expand Up @@ -108,16 +109,19 @@ type QueryParameter struct {
// Arrays and slices of the above.
// Structs of the above. Only the exported fields are used.
//
// BigQuery does not support params of type GEOGRAPHY. For users wishing
// to parameterize Geography values, use string parameters and cast in the
// SQL query, e.g. `SELECT ST_GeogFromText(@string_param) as geo`
// For scalar values, you can supply the Null types within this library
// to send the appropriate NULL values (e.g. NullInt64, NullString, etc).
//
// When a QueryParameter is returned inside a QueryConfig from a call to
// Job.Config:
// Integers are of type int64.
// Floating-point values are of type float64.
// Arrays are of type []interface{}, regardless of the array element type.
// Structs are of type map[string]interface{}.
//
// When valid (non-null) Null types are sent, they come back as the Go types indicated
// above. Null strings will report in query statistics as a valid empty
// string.
Value interface{}
}

Expand All @@ -142,16 +146,26 @@ func paramType(t reflect.Type) (*bq.QueryParameterType, error) {
return nil, errors.New("bigquery: nil parameter")
}
switch t {
case typeOfDate:
case typeOfDate, typeOfNullDate:
return dateParamType, nil
case typeOfTime:
case typeOfTime, typeOfNullTime:
return timeParamType, nil
case typeOfDateTime:
case typeOfDateTime, typeOfNullDateTime:
return dateTimeParamType, nil
case typeOfGoTime:
case typeOfGoTime, typeOfNullTimestamp:
return timestampParamType, nil
case typeOfRat:
return numericParamType, nil
case typeOfNullBool:
return boolParamType, nil
case typeOfNullFloat64:
return float64ParamType, nil
case typeOfNullInt64:
return int64ParamType, nil
case typeOfNullString:
return stringParamType, nil
case typeOfNullGeography:
return geographyParamType, nil
}
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint8, reflect.Uint16, reflect.Uint32:
Expand Down Expand Up @@ -214,10 +228,52 @@ func paramValue(v reflect.Value) (bq.QueryParameterValue, error) {
}
t := v.Type()
switch t {

// Handle all the custom null types as a group first, as they all have the same logic when invalid.
case typeOfNullInt64,
typeOfNullString,
typeOfNullGeography,
typeOfNullFloat64,
typeOfNullBool,
typeOfNullTimestamp,
typeOfNullDate,
typeOfNullTime,
typeOfNullDateTime:
// Shared: If the Null type isn't valid, we're not sending a value.
if !v.FieldByName("Valid").Bool() {
return res, nil
}
// Now, handle each Null type seperately, where we have a value.
switch t {
case typeOfNullInt64:
res.Value = fmt.Sprint(v.FieldByName("Int64").Interface())
case typeOfNullString:
res.Value = fmt.Sprint(v.FieldByName("StringVal").Interface())
case typeOfNullGeography:
res.Value = fmt.Sprint(v.FieldByName("GeographyVal").Interface())
case typeOfNullFloat64:
res.Value = fmt.Sprint(v.FieldByName("Float64").Interface())
case typeOfNullBool:
res.Value = fmt.Sprint(v.FieldByName("Bool").Interface())
case typeOfNullTimestamp:
res.Value = v.FieldByName("Timestamp").Interface().(time.Time).Format(timestampFormat)
case typeOfNullDate:
res.Value = v.FieldByName("Date").Interface().(civil.Date).String()
case typeOfNullTime:
res.Value = CivilTimeString(v.FieldByName("Time").Interface().(civil.Time))
case typeOfNullDateTime:
res.Value = CivilDateTimeString(v.FieldByName("DateTime").Interface().(civil.DateTime))
}
// We expect to produce a value in all these cases, so force send if the result is the empty
// string.
if res.Value == "" {
res.ForceSendFields = append(res.ForceSendFields, "Value")
}
return res, nil

case typeOfDate:
res.Value = v.Interface().(civil.Date).String()
return res, nil

case typeOfTime:
// civil.Time has nanosecond resolution, but BigQuery TIME only microsecond.
// (If we send nanoseconds, then when we try to read the result we get "query job
Expand Down Expand Up @@ -317,6 +373,7 @@ var paramTypeToFieldType = map[string]FieldType{
timeParamType.Type: TimeFieldType,
numericParamType.Type: NumericFieldType,
bigNumericParamType.Type: BigNumericFieldType,
geographyParamType.Type: GeographyFieldType,
}

// Convert a parameter value from the service to a Go value. This is similar to, but
Expand All @@ -334,10 +391,36 @@ func convertParamValue(qval *bq.QueryParameterValue, qtype *bq.QueryParameterTyp
}
return convertParamStruct(qval.StructValues, qtype.StructTypes)
case "TIMESTAMP":
if qval.Value == "" {
return NullTimestamp{Valid: false}, nil
}
return time.Parse(timestampFormat, qval.Value)
case "DATETIME":
if qval.Value == "" {
return NullDateTime{Valid: false}, nil
}
return parseCivilDateTime(qval.Value)
default:
if qval.Value == "" {
switch qtype.Type {
case "INT64":
return NullInt64{Valid: false}, nil
case "FLOAT64":
return NullFloat64{Valid: false}, nil
case "BOOL":
return NullBool{Valid: false}, nil
case "DATE":
return NullDate{Valid: false}, nil
case "TIME":
return NullTime{Valid: false}, nil
case "GEOGRAPHY":
// TODO: decide if empty geography string should represent empty or null
return NullGeography{Valid: false}, nil
}
}
// Note: we cannot tell the difference between a null and empty string
// in the *bq.QueryParameterValue from backend due to how JSON is handled,
// so we retain the existing behavior that it's treated an empty string.
return convertBasicType(qval.Value, paramTypeToFieldType[qtype.Type])
}
}
Expand Down
107 changes: 80 additions & 27 deletions bigquery/params_test.go
Expand Up @@ -30,27 +30,71 @@ import (
)

var scalarTests = []struct {
val interface{} // The Go value
wantVal string // paramValue's desired output
wantType *bq.QueryParameterType // paramType's desired output
val interface{} // The Go value
wantVal string // paramValue's desired output
wantType *bq.QueryParameterType // paramType's desired output
roundTripOk bool // Value can roundtrip without issue.
}{
{int64(0), "0", int64ParamType},
{3.14, "3.14", float64ParamType},
{3.14159e-87, "3.14159e-87", float64ParamType},
{true, "true", boolParamType},
{"string", "string", stringParamType},
{"\u65e5\u672c\u8a9e\n", "\u65e5\u672c\u8a9e\n", stringParamType},
{math.NaN(), "NaN", float64ParamType},
{[]byte("foo"), "Zm9v", bytesParamType}, // base64 encoding of "foo"
{int64(0), "0", int64ParamType, true},
{NullInt64{Int64: 3, Valid: true}, "3", int64ParamType, false},
{NullInt64{Valid: false}, "", int64ParamType, true},
{3.14, "3.14", float64ParamType, true},
{3.14159e-87, "3.14159e-87", float64ParamType, true},
{NullFloat64{Float64: 3.14, Valid: true}, "3.14", float64ParamType, false},
{NullFloat64{Valid: false}, "", float64ParamType, true},
{math.NaN(), "NaN", float64ParamType, true},
{true, "true", boolParamType, true},
{NullBool{Bool: true, Valid: true}, "true", boolParamType, false},
{NullBool{Valid: false}, "", boolParamType, true},
{"string", "string", stringParamType, true},
{"\u65e5\u672c\u8a9e\n", "\u65e5\u672c\u8a9e\n", stringParamType, true},
{NullString{StringVal: "string", Valid: true}, "string", stringParamType, false},
{NullString{Valid: false}, "", stringParamType, false}, // Cannot detect null strings on roundtrip.
{[]byte("foo"), "Zm9v", bytesParamType, true}, // base64 encoding of "foo"
{time.Date(2016, 3, 20, 4, 22, 9, 5000, time.FixedZone("neg1-2", -3720)),
"2016-03-20 04:22:09.000005-01:02",
timestampParamType},
{civil.Date{Year: 2016, Month: 3, Day: 20}, "2016-03-20", dateParamType},
{civil.Time{Hour: 4, Minute: 5, Second: 6, Nanosecond: 789000000}, "04:05:06.789000", timeParamType},
timestampParamType,
true},
{NullTimestamp{Timestamp: time.Date(2016, 3, 20, 4, 22, 9, 5000, time.FixedZone("neg1-2", -3720)), Valid: true},
"2016-03-20 04:22:09.000005-01:02",
timestampParamType,
false},
{NullTimestamp{Valid: false},
"",
timestampParamType,
true},
{civil.Date{Year: 2016, Month: 3, Day: 20}, "2016-03-20", dateParamType, true},
{NullDate{
Date: civil.Date{Year: 2016, Month: 3, Day: 20}, Valid: true},
"2016-03-20",
dateParamType, false},
{NullDate{
Valid: false},
"",
dateParamType, true},
{civil.Time{Hour: 4, Minute: 5, Second: 6, Nanosecond: 789000000}, "04:05:06.789000", timeParamType, true},
{NullTime{
Time: civil.Time{Hour: 5, Minute: 6, Second: 7, Nanosecond: 789000000}, Valid: true},
"05:06:07.789000",
timeParamType, false},
{NullTime{
Valid: false},
"",
timeParamType, true},
{civil.DateTime{Date: civil.Date{Year: 2016, Month: 3, Day: 20}, Time: civil.Time{Hour: 4, Minute: 5, Second: 6, Nanosecond: 789000000}},
"2016-03-20 04:05:06.789000",
dateTimeParamType},
{big.NewRat(12345, 1000), "12.345000000", numericParamType},
dateTimeParamType, true},
{NullDateTime{
DateTime: civil.DateTime{Date: civil.Date{Year: 2016, Month: 3, Day: 21}, Time: civil.Time{Hour: 4, Minute: 5, Second: 6, Nanosecond: 789000000}}, Valid: true},
"2016-03-21 04:05:06.789000",
dateTimeParamType, false},
{NullDateTime{
Valid: false},
"",
dateTimeParamType, true},
{big.NewRat(12345, 1000), "12.345000000", numericParamType, true},
{NullGeography{GeographyVal: "foo", Valid: true}, "foo", geographyParamType, false},
{NullGeography{Valid: false}, "", geographyParamType, true},
}

type (
Expand Down Expand Up @@ -224,9 +268,16 @@ func TestConvertParamValue(t *testing.T) {
if err != nil {
t.Fatalf("convertParamValue(%+v, %+v): %v", pval, ptype, err)
}
if !testutil.Equal(got, test.val) {
t.Errorf("%#v: got %#v", test.val, got)
if test.roundTripOk {
if !testutil.Equal(got, test.val) {
t.Errorf("%#v: got %#v", test.val, got)
}
} else {
if testutil.Equal(got, test.val) {
t.Errorf("%#v: roundtripped equal but shouldn't, got %#v", test.val, got)
}
}

}
// Arrays.
for _, test := range []struct {
Expand Down Expand Up @@ -268,15 +319,17 @@ func TestIntegration_ScalarParam(t *testing.T) {
func(t time.Time) time.Time { return t.Round(time.Microsecond) })
c := getClient(t)
for _, test := range scalarTests {
gotData, gotParam, err := paramRoundTrip(c, test.val)
if err != nil {
t.Fatal(err)
}
if !testutil.Equal(gotData, test.val, roundToMicros) {
t.Errorf("\ngot %#v (%T)\nwant %#v (%T)", gotData, gotData, test.val, test.val)
}
if !testutil.Equal(gotParam, test.val, roundToMicros) {
t.Errorf("\ngot %#v (%T)\nwant %#v (%T)", gotParam, gotParam, test.val, test.val)
if test.roundTripOk {
gotData, gotParam, err := paramRoundTrip(c, test.val)
if err != nil {
t.Fatal(err)
}
if !testutil.Equal(gotData, test.val, roundToMicros) {
t.Errorf("\ngot %#v (%T)\nwant %#v (%T)", gotData, gotData, test.val, test.val)
}
if !testutil.Equal(gotParam, test.val, roundToMicros) {
t.Errorf("\ngot %#v (%T)\nwant %#v (%T)", gotParam, gotParam, test.val, test.val)
}
}
}
}
Expand Down