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 all 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
138 changes: 120 additions & 18 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 @@ -132,7 +136,7 @@ func (p QueryParameter) toBQ() (*bq.QueryParameter, error) {
}
return &bq.QueryParameter{
Name: p.Name,
ParameterValue: &pv,
ParameterValue: pv,
ParameterType: pt,
}, nil
}
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 @@ -207,17 +221,64 @@ func paramType(t reflect.Type) (*bq.QueryParameterType, error) {
return nil, fmt.Errorf("bigquery: Go type %s cannot be represented as a parameter type", t)
}

func paramValue(v reflect.Value) (bq.QueryParameterValue, error) {
var res bq.QueryParameterValue
func paramValue(v reflect.Value) (*bq.QueryParameterValue, error) {
res := &bq.QueryParameterValue{}
if !v.IsValid() {
return res, errors.New("bigquery: nil parameter")
}
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 have no value to send.
// However, the backend requires us to send the QueryParameterValue with
// the fields empty.
if !v.FieldByName("Valid").Bool() {
// Ensure we don't send a default value by using NullFields in the JSON
// serialization.
res.NullFields = append(res.NullFields, "Value")
return res, nil
}
// For cases where the Null type is valid, populate the scalar value as needed.
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 @@ -253,11 +314,11 @@ func paramValue(v reflect.Value) (bq.QueryParameterValue, error) {
for i := 0; i < v.Len(); i++ {
val, err := paramValue(v.Index(i))
if err != nil {
return bq.QueryParameterValue{}, err
return nil, err
}
vals = append(vals, &val)
vals = append(vals, val)
}
return bq.QueryParameterValue{ArrayValues: vals}, nil
return &bq.QueryParameterValue{ArrayValues: vals}, nil

case reflect.Ptr:
if t.Elem().Kind() != reflect.Struct {
Expand All @@ -274,16 +335,16 @@ func paramValue(v reflect.Value) (bq.QueryParameterValue, error) {
case reflect.Struct:
fields, err := fieldCache.Fields(t)
if err != nil {
return bq.QueryParameterValue{}, err
return nil, err
}
res.StructValues = map[string]bq.QueryParameterValue{}
for _, f := range fields {
fv := v.FieldByIndex(f.Index)
fp, err := paramValue(fv)
if err != nil {
return bq.QueryParameterValue{}, err
return nil, err
}
res.StructValues[f.Name] = fp
res.StructValues[f.Name] = *fp
}
return res, nil
}
Expand Down Expand Up @@ -317,10 +378,12 @@ 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
// not quite the same as, converting data values.
// not quite the same as, converting data values. Namely, rather than returning nil
// directly, we wrap them in the appropriate Null types (NullInt64, etc).
func convertParamValue(qval *bq.QueryParameterValue, qtype *bq.QueryParameterType) (interface{}, error) {
switch qtype.Type {
case "ARRAY":
Expand All @@ -334,14 +397,53 @@ func convertParamValue(qval *bq.QueryParameterValue, qtype *bq.QueryParameterTyp
}
return convertParamStruct(qval.StructValues, qtype.StructTypes)
case "TIMESTAMP":
if isNullScalar(qval) {
return NullTimestamp{Valid: false}, nil
}
return time.Parse(timestampFormat, qval.Value)
case "DATETIME":
if isNullScalar(qval) {
return NullDateTime{Valid: false}, nil
}
return parseCivilDateTime(qval.Value)
default:
if isNullScalar(qval) {
switch qtype.Type {
case "INT64":
return NullInt64{Valid: false}, nil
case "STRING":
return NullString{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":
return NullGeography{Valid: false}, nil
}

}
return convertBasicType(qval.Value, paramTypeToFieldType[qtype.Type])
}
}

// isNullScalar determines if the input is meant to represent a null scalar
// value.
func isNullScalar(qval *bq.QueryParameterValue) bool {
if qval == nil {
return true
}
for _, v := range qval.NullFields {
if v == "Value" {
return true
}
}
return false
}

// convertParamArray converts a query parameter array value to a Go value. It
// always returns a []interface{}.
func convertParamArray(elVals []*bq.QueryParameterValue, elType *bq.QueryParameterType) ([]interface{}, error) {
Expand Down