diff --git a/drivers/metadata/informationschema/metadata.go b/drivers/metadata/informationschema/metadata.go index f95a304fd56..28760834793 100644 --- a/drivers/metadata/informationschema/metadata.go +++ b/drivers/metadata/informationschema/metadata.go @@ -6,6 +6,8 @@ package informationschema import ( "database/sql" "fmt" + "reflect" + "strconv" "strings" "github.com/xo/usql/drivers" @@ -13,6 +15,133 @@ import ( "github.com/xo/usql/text" ) +// Scan targets that absorb a SQL NULL instead of failing the whole listing. +// +// Most of the queries in this file guard nullable columns with COALESCE(x, ”) +// because database/sql cannot convert NULL into a string or int. That guard is +// useless on openGauss running in Oracle-compatible mode (sql_compatibility=A), +// where the empty string literal ” is itself NULL -- so COALESCE(x, ”) +// evaluates to COALESCE(x, NULL) and the NULL still reaches the scanner. +// Scanning through these types makes the queries tolerate NULL regardless of +// which server semantics apply. +type nullStr struct{ p *string } + +func (n nullStr) Scan(v any) error { + switch t := v.(type) { + case nil: + *n.p = "" + case string: + *n.p = t + case []byte: + *n.p = string(t) + default: + return fmt.Errorf("informationschema: cannot scan %T into string", v) + } + return nil +} + +// scanAsString mirrors database/sql's unexported asString, which is how the +// standard library renders a driver value before parsing it into a numeric +// destination. Replicating it keeps nullInt exactly as permissive as the +// plain *int targets it replaces, so no value type that used to convert +// (numeric-as-text, bool, uint, ...) now fails on the other databases that +// share this information_schema reader. +func scanAsString(v any) (string, bool) { + switch t := v.(type) { + case string: + return t, true + case []byte: + return string(t), true + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(rv.Int(), 10), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(rv.Uint(), 10), true + case reflect.Float32: + return strconv.FormatFloat(rv.Float(), 'g', -1, 32), true + case reflect.Float64: + return strconv.FormatFloat(rv.Float(), 'g', -1, 64), true + case reflect.Bool: + return strconv.FormatBool(rv.Bool()), true + } + return "", false +} + +type nullInt struct{ p *int } + +func (n nullInt) Scan(v any) error { + if v == nil { + *n.p = 0 + return nil + } + s, ok := scanAsString(v) + if !ok { + return fmt.Errorf("informationschema: cannot scan %T into int", v) + } + if s == "" { + *n.p = 0 + return nil + } + i, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("informationschema: cannot scan %T (%q) into int: %w", v, s, err) + } + *n.p = int(i) + return nil +} + +type nullBool struct{ p *metadata.Bool } + +func (n nullBool) Scan(v any) error { + switch t := v.(type) { + case nil: + *n.p = "" + case string: + *n.p = metadata.Bool(t) + case []byte: + *n.p = metadata.Bool(t) + default: + return fmt.Errorf("informationschema: cannot scan %T into Bool", v) + } + return nil +} + +// nullBoolVal is nullBool for plain bool destinations, e.g. is_grantable. +type nullBoolVal struct{ p *bool } + +func (n nullBoolVal) Scan(v any) error { + switch t := v.(type) { + case nil: + *n.p = false + case bool: + *n.p = t + case int64: + // information_schema.yes_or_no is a domain over boolean in + // PostgreSQL, but openGauss reports it as a numeric YES/NO, so the + // driver hands it back as int64. + *n.p = t != 0 + case []byte: + switch s := string(t); s { + case "", "NO", "no", "f", "false", "0": + *n.p = false + default: + *n.p = true + } + case string: + switch t { + case "", "NO", "no", "f", "false", "0": + *n.p = false + default: + *n.p = true + } + default: + return fmt.Errorf("informationschema: cannot scan %T into bool", v) + } + return nil +} + // InformationSchema metadata reader type InformationSchema struct { metadata.LoggingReader @@ -240,6 +369,12 @@ func (s InformationSchema) Columns(f metadata.Filter) (*metadata.ColumnSet, erro results := []metadata.Column{} for rows.Next() { rec := metadata.Column{} + // column_default/is_nullable are NULL for columns without a default, + // and under openGauss's Oracle-compatible mode (sql_compatibility=A) + // the '' default passed to COALESCE is itself NULL, so a COALESCE + // alone is not enough: scan via sql.Null* to absorb NULLs. + var def, isNullable sql.NullString + var size, digits, radix, octetLength sql.NullInt64 err = rows.Scan( &rec.Catalog, &rec.Schema, @@ -247,16 +382,22 @@ func (s InformationSchema) Columns(f metadata.Filter) (*metadata.ColumnSet, erro &rec.Name, &rec.OrdinalPosition, &rec.DataType, - &rec.Default, - &rec.IsNullable, - &rec.ColumnSize, - &rec.DecimalDigits, - &rec.NumPrecRadix, - &rec.CharOctetLength, + &def, + &isNullable, + &size, + &digits, + &radix, + &octetLength, ) if err != nil { return nil, err } + rec.Default = def.String + rec.IsNullable = metadata.Bool(isNullable.String) + rec.ColumnSize = int(size.Int64) + rec.DecimalDigits = int(digits.Int64) + rec.NumPrecRadix = int(radix.Int64) + rec.CharOctetLength = int(octetLength.Int64) rec.DataType = s.dataTypeFormatter(rec) results = append(results, rec) } @@ -412,16 +553,16 @@ func (s InformationSchema) Functions(f metadata.Filter) (*metadata.FunctionSet, for rows.Next() { rec := metadata.Function{} err = rows.Scan( - &rec.SpecificName, - &rec.Catalog, - &rec.Schema, - &rec.Name, - &rec.Type, - &rec.ResultType, - &rec.Source, - &rec.Language, - &rec.Volatility, - &rec.Security, + nullStr{&rec.SpecificName}, + nullStr{&rec.Catalog}, + nullStr{&rec.Schema}, + nullStr{&rec.Name}, + nullStr{&rec.Type}, + nullStr{&rec.ResultType}, + nullStr{&rec.Source}, + nullStr{&rec.Language}, + nullStr{&rec.Volatility}, + nullStr{&rec.Security}, ) if err != nil { return nil, err @@ -475,17 +616,17 @@ func (s InformationSchema) FunctionColumns(f metadata.Filter) (*metadata.Functio for rows.Next() { rec := metadata.FunctionColumn{} err = rows.Scan( - &rec.Catalog, - &rec.Schema, - &rec.FunctionName, - &rec.Name, - &rec.OrdinalPosition, - &rec.Type, - &rec.DataType, - &rec.ColumnSize, - &rec.DecimalDigits, - &rec.NumPrecRadix, - &rec.CharOctetLength, + nullStr{&rec.Catalog}, + nullStr{&rec.Schema}, + nullStr{&rec.FunctionName}, + nullStr{&rec.Name}, + nullInt{&rec.OrdinalPosition}, + nullStr{&rec.Type}, + nullStr{&rec.DataType}, + nullInt{&rec.ColumnSize}, + nullInt{&rec.DecimalDigits}, + nullInt{&rec.NumPrecRadix}, + nullInt{&rec.CharOctetLength}, ) if err != nil { return nil, err @@ -594,7 +735,7 @@ JOIN information_schema.columns c ON results := []metadata.IndexColumn{} for rows.Next() { rec := metadata.IndexColumn{} - err = rows.Scan(&rec.Catalog, &rec.Schema, &rec.Table, &rec.IndexName, &rec.Name, &rec.DataType, &rec.OrdinalPosition) + err = rows.Scan(nullStr{&rec.Catalog}, nullStr{&rec.Schema}, nullStr{&rec.Table}, nullStr{&rec.IndexName}, nullStr{&rec.Name}, nullStr{&rec.DataType}, nullInt{&rec.OrdinalPosition}) if err != nil { return nil, err } @@ -668,21 +809,21 @@ LEFT JOIN information_schema.check_constraints c ON t.constraint_catalog = c.con for rows.Next() { rec := metadata.Constraint{} err = rows.Scan( - &rec.Catalog, - &rec.Schema, - &rec.Table, - &rec.Name, - &rec.Type, - &rec.IsDeferrable, - &rec.IsInitiallyDeferred, - &rec.ForeignCatalog, - &rec.ForeignSchema, - &rec.ForeignTable, - &rec.ForeignName, - &rec.MatchType, - &rec.UpdateRule, - &rec.DeleteRule, - &rec.CheckClause, + nullStr{&rec.Catalog}, + nullStr{&rec.Schema}, + nullStr{&rec.Table}, + nullStr{&rec.Name}, + nullStr{&rec.Type}, + nullBool{&rec.IsDeferrable}, + nullBool{&rec.IsInitiallyDeferred}, + nullStr{&rec.ForeignCatalog}, + nullStr{&rec.ForeignSchema}, + nullStr{&rec.ForeignTable}, + nullStr{&rec.ForeignName}, + nullStr{&rec.MatchType}, + nullStr{&rec.UpdateRule}, + nullStr{&rec.DeleteRule}, + nullStr{&rec.CheckClause}, ) if err != nil { return nil, err @@ -780,16 +921,16 @@ LEFT JOIN information_schema.key_column_usage f ON r.unique_constraint_catalog = rec := metadata.ConstraintColumn{OrdinalPosition: i} i++ err = rows.Scan( - &rec.Catalog, - &rec.Schema, - &rec.Table, - &rec.Constraint, - &rec.Name, - &rec.OrdinalPosition, - &rec.ForeignCatalog, - &rec.ForeignSchema, - &rec.ForeignTable, - &rec.ForeignName, + nullStr{&rec.Catalog}, + nullStr{&rec.Schema}, + nullStr{&rec.Table}, + nullStr{&rec.Constraint}, + nullStr{&rec.Name}, + nullInt{&rec.OrdinalPosition}, + nullStr{&rec.ForeignCatalog}, + nullStr{&rec.ForeignSchema}, + nullStr{&rec.ForeignTable}, + nullStr{&rec.ForeignName}, ) if err != nil { return nil, err @@ -839,7 +980,7 @@ func (s InformationSchema) Sequences(f metadata.Filter) (*metadata.SequenceSet, results := []metadata.Sequence{} for rows.Next() { rec := metadata.Sequence{} - err = rows.Scan(&rec.Catalog, &rec.Schema, &rec.Name, &rec.DataType, &rec.Start, &rec.Min, &rec.Max, &rec.Increment, &rec.Cycles) + err = rows.Scan(nullStr{&rec.Catalog}, nullStr{&rec.Schema}, nullStr{&rec.Name}, nullStr{&rec.DataType}, nullStr{&rec.Start}, nullStr{&rec.Min}, nullStr{&rec.Max}, nullStr{&rec.Increment}, nullBool{&rec.Cycles}) if err != nil { return nil, err } @@ -962,7 +1103,7 @@ func (s InformationSchema) PrivilegeSummaries(f metadata.Filter) (*metadata.Priv curSummary := &metadata.PrivilegeSummary{} for rows.Next() { r := row{} - err = rows.Scan(&r.Catalog, &r.Schema, &r.Name, &r.ObjectType, &r.Column, &r.Grantee, &r.Grantor, &r.PrivilegeType, &r.IsGrantable) + err = rows.Scan(nullStr{&r.Catalog}, nullStr{&r.Schema}, nullStr{&r.Name}, nullStr{&r.ObjectType}, nullStr{&r.Column}, nullStr{&r.Grantee}, nullStr{&r.Grantor}, nullStr{&r.PrivilegeType}, nullBoolVal{&r.IsGrantable}) if err != nil { return nil, err } diff --git a/drivers/metadata/informationschema/nullscan_test.go b/drivers/metadata/informationschema/nullscan_test.go new file mode 100644 index 00000000000..0fd1217f1bd --- /dev/null +++ b/drivers/metadata/informationschema/nullscan_test.go @@ -0,0 +1,173 @@ +package informationschema + +import ( + "testing" + + "github.com/xo/usql/drivers/metadata" +) + +// errUnsupported stands in for the "converting NULL to X is unsupported" error +// database/sql raises, which the null* targets deliberately avoid. +type errUnsupported struct{ what string } + +func (e errUnsupported) Error() string { return "converting NULL to " + e.what + " is unsupported" } + +// nativeInt mirrors database/sql's conversion for an *int destination: it +// renders the driver value with asString and then parses it. Any value this +// accepts, nullInt must accept too. +func nativeInt(v any) (int, error) { + s, ok := scanAsString(v) + if !ok { + return 0, errUnsupported{"int"} + } + if s == "" { + return 0, nil + } + var n int + neg := false + i := 0 + if s[0] == '-' || s[0] == '+' { + neg = s[0] == '-' + i++ + } + if i == len(s) { + return 0, errUnsupported{"int"} + } + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, errUnsupported{"int"} + } + n = n*10 + int(s[i]-'0') + } + if neg { + n = -n + } + return n, nil +} + +// nativeStr mirrors database/sql's conversion for a *string destination, which +// accepts only string and []byte. +func nativeStr(v any) (string, error) { + switch t := v.(type) { + case string: + return t, nil + case []byte: + return string(t), nil + } + return "", errUnsupported{"string"} +} + +var scanValues = []any{ + nil, + int64(42), int64(0), int64(-7), + float64(3.5), float64(0), + "17", "", "abc", "-3", + []byte("23"), []byte(nil), []byte("abc"), + true, false, + uint64(9), int32(5), +} + +// A narrower target would silently break the other databases that share this +// information_schema reader, so nullInt/nullStr must accept exactly what +// database/sql accepts -- and additionally absorb NULL. +func TestNullTargetsMatchDatabaseSQL(t *testing.T) { + t.Run("nullInt", func(t *testing.T) { + for _, v := range scanValues { + var got int + gotErr := nullInt{&got}.Scan(v) + want, wantErr := nativeInt(v) + if v == nil { + wantErr = errUnsupported{"int"} + } + assertSame(t, "nullInt", v, got, gotErr, want, wantErr) + } + }) + t.Run("nullStr", func(t *testing.T) { + for _, v := range scanValues { + var got string + gotErr := nullStr{&got}.Scan(v) + var want string + var wantErr error + if v == nil { + wantErr = errUnsupported{"string"} + } else { + want, wantErr = nativeStr(v) + } + assertSame(t, "nullStr", v, got, gotErr, want, wantErr) + } + }) + t.Run("nullBool", func(t *testing.T) { + for _, v := range scanValues { + var got metadata.Bool + gotErr := nullBool{&got}.Scan(v) + var want metadata.Bool + var wantErr error + if v == nil { + wantErr = errUnsupported{"Bool"} + } else { + s, err := nativeStr(v) + want, wantErr = metadata.Bool(s), err + } + assertSame(t, "nullBool", v, string(got), gotErr, string(want), wantErr) + } + }) +} + +func assertSame[T comparable](t *testing.T, fn string, v any, got T, gotErr error, want T, wantErr error) { + t.Helper() + if (gotErr == nil) != (wantErr == nil) { + t.Errorf("%s(%T(%v)): got err=%v, database/sql err=%v", fn, v, v, gotErr, wantErr) + return + } + if gotErr == nil && got != want { + t.Errorf("%s(%T(%v)): got %v, database/sql %v", fn, v, v, got, want) + } +} + +// TestNullTargetsAbsorbNULL is the whole point of the change: NULL must be +// absorbed rather than aborting the metadata listing. +func TestNullTargetsAbsorbNULL(t *testing.T) { + var s string + var i int + var b metadata.Bool + var bv bool + for _, tc := range []struct { + name string + fn func() error + }{ + {"nullStr", func() error { return (nullStr{&s}).Scan(nil) }}, + {"nullInt", func() error { return (nullInt{&i}).Scan(nil) }}, + {"nullBool", func() error { return (nullBool{&b}).Scan(nil) }}, + {"nullBoolVal", func() error { return (nullBoolVal{&bv}).Scan(nil) }}, + } { + if err := tc.fn(); err != nil { + t.Errorf("%s(nil) = %v, want nil", tc.name, err) + } + } + if s != "" || i != 0 || b != "" || bv { + t.Errorf("NULL did not zero out: %q %d %q %v", s, i, b, bv) + } +} + +// nullBoolVal additionally has to cope with information_schema.yes_or_no, a +// domain over boolean in PostgreSQL but reported as a number by openGauss. +func TestNullBoolValAcceptsYesNo(t *testing.T) { + for _, tc := range []struct { + in any + want bool + }{ + {true, true}, {false, false}, + {int64(1), true}, {int64(0), false}, + {[]byte("YES"), true}, {[]byte("NO"), false}, + {"YES", true}, {"NO", false}, + } { + var got bool + if err := (nullBoolVal{&got}).Scan(tc.in); err != nil { + t.Errorf("nullBoolVal(%v) = %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("nullBoolVal(%v) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/drivers/metadata/postgres/metadata.go b/drivers/metadata/postgres/metadata.go index 7ab0ab4c8de..22c7aee9d51 100644 --- a/drivers/metadata/postgres/metadata.go +++ b/drivers/metadata/postgres/metadata.go @@ -117,10 +117,19 @@ FROM pg_catalog.pg_database d` rec := Catalog{ Catalog: metadata.Catalog{}, } - err = rows.Scan(&rec.Catalog.Catalog, &rec.Owner, &rec.Encoding, &rec.Collate, &rec.Ctype, &rec.AccessPrivileges) + // datacl is NULL for databases without explicit ACLs, and in + // openGauss Oracle-compatible mode (sql_compatibility=A) the COALESCE + // default of '' is itself NULL, so scan via sql.Null*. + var owner, encoding, collate, ctype, access sql.NullString + err = rows.Scan(&rec.Catalog.Catalog, &owner, &encoding, &collate, &ctype, &access) if err != nil { return nil, err } + rec.Owner = owner.String + rec.Encoding = encoding.String + rec.Collate = collate.String + rec.Ctype = ctype.String + rec.AccessPrivileges = access.String results = append(results, &rec) } if rows.Err() != nil { @@ -134,8 +143,8 @@ func (r metaReader) Tables(f metadata.Filter) (*metadata.TableSet, error) { c.relname as "Name", CASE c.relkind WHEN 'r' THEN 'table' WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' WHEN 'i' THEN 'index' WHEN 'S' THEN 'sequence' WHEN 's' THEN 'special' WHEN 'f' THEN 'foreign table' WHEN 'p' THEN 'partitioned table' WHEN 'I' THEN 'partitioned index' ELSE 'unknown' END as "Type", COALESCE((c.reltuples / NULLIF(c.relpages, 0)) * (pg_catalog.pg_relation_size(c.oid) / current_setting('block_size')::int), 0)::bigint as "Rows", - pg_catalog.pg_size_pretty(pg_catalog.pg_table_size(c.oid)) as "Size", - COALESCE(pg_catalog.obj_description(c.oid, 'pg_class'), '') as "Description" + CAST(pg_catalog.pg_size_pretty(pg_catalog.pg_table_size(c.oid)) AS text) as "Size", + CAST(COALESCE(pg_catalog.obj_description(c.oid, 'pg_class'), '') AS text) as "Description" FROM pg_catalog.pg_class c LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ` @@ -183,10 +192,20 @@ FROM pg_catalog.pg_class c results := []metadata.Table{} for rows.Next() { rec := metadata.Table{} - err = rows.Scan(&rec.Schema, &rec.Name, &rec.Type, &rec.Rows, &rec.Size, &rec.Comment) + // scan via sql.Null* : PostgreSQL-compatible servers (notably + // openGauss/GaussDB) may return NULL for these columns despite the + // COALESCE above, and scanning NULL into a string errors out. + var schema, typ, size, comment sql.NullString + var rowCount sql.NullInt64 + err = rows.Scan(&schema, &rec.Name, &typ, &rowCount, &size, &comment) if err != nil { return nil, err } + rec.Schema = schema.String + rec.Type = typ.String + rec.Rows = rowCount.Int64 + rec.Size = size.String + rec.Comment = comment.String results = append(results, rec) } if rows.Err() != nil { @@ -320,10 +339,20 @@ FROM pg_catalog.pg_class c results := []metadata.Index{} for rows.Next() { rec := metadata.Index{} - err = rows.Scan(&rec.Catalog, &rec.Schema, &rec.Table, &rec.Name, &rec.IsUnique, &rec.IsPrimary, &rec.Type) + // LEFT JOINs on pg_class/pg_am can yield NULL rows; scan via + // sql.Null* so that a NULL does not abort the whole listing. + var catalog, schema, table, name, isUnique, isPrimary, typ sql.NullString + err = rows.Scan(&catalog, &schema, &table, &name, &isUnique, &isPrimary, &typ) if err != nil { return nil, err } + rec.Catalog = catalog.String + rec.Schema = schema.String + rec.Table = table.String + rec.Name = name.String + rec.IsUnique = metadata.Bool(isUnique.String) + rec.IsPrimary = metadata.Bool(isPrimary.String) + rec.Type = typ.String results = append(results, rec) } if rows.Err() != nil {