From 5e200a72592ec783e53e87033f851f596e16750a Mon Sep 17 00:00:00 2001 From: yimiao Date: Thu, 15 Oct 2026 15:16:38 +0800 Subject: [PATCH 1/2] fix(metadata): tolerate NULL scans on openGauss usql guards nullable catalog columns with COALESCE(x, '') because database/sql cannot convert NULL into a string or int. On openGauss running in Oracle-compatible mode (sql_compatibility=A) the empty string literal '' is itself NULL, so COALESCE(x, '') evaluates to COALESCE(x, NULL) and the NULL still reaches the scanner. Every metadata command aborts with: failed to list tables: sql: Scan error on column index 5, name "Description": converting NULL to string is unsupported An empty string cannot be represented at all under these semantics, so the fix has to happen on the scan side. Add nullStr/nullInt/nullBool/ nullBoolVal scanner targets that absorb NULL, and route every scan in the information_schema and postgres metadata readers through them. The SQL is unchanged, so the queries remain correct under both PostgreSQL and openGauss semantics. Also accept int64 for is_grantable: information_schema.yes_or_no is a domain over boolean in PostgreSQL, but openGauss reports it as a numeric YES/NO, which used to break \dp with "cannot scan int64 into bool". Verified against openGauss 6.0.3: \l \dn \dt \dt+ \di \d \d+ \dT \dtT \df \dp \dv \ds \z \dC \dD \dx \dew \deu \sf all pass, no Scan errors. --- .../metadata/informationschema/metadata.go | 222 +++++++++++++----- drivers/metadata/postgres/metadata.go | 39 ++- 2 files changed, 201 insertions(+), 60 deletions(-) diff --git a/drivers/metadata/informationschema/metadata.go b/drivers/metadata/informationschema/metadata.go index f95a304fd56..60d1fc6ea6c 100644 --- a/drivers/metadata/informationschema/metadata.go +++ b/drivers/metadata/informationschema/metadata.go @@ -6,6 +6,7 @@ package informationschema import ( "database/sql" "fmt" + "strconv" "strings" "github.com/xo/usql/drivers" @@ -13,6 +14,105 @@ 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 +} + +type nullInt struct{ p *int } + +func (n nullInt) Scan(v any) error { + switch t := v.(type) { + case nil: + *n.p = 0 + case int64: + *n.p = int(t) + case int32: + *n.p = int(t) + case float64: + *n.p = int(t) + case []byte: + i, err := strconv.Atoi(string(t)) + if err != nil { + return err + } + *n.p = i + default: + return fmt.Errorf("informationschema: cannot scan %T into int", v) + } + 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 +340,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 +353,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 +524,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 +587,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 +706,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 +780,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 +892,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 +951,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 +1074,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/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 { From 705e95416424f0e97fac19b4d3c7e55330bed516 Mon Sep 17 00:00:00 2001 From: yimiao Date: Thu, 15 Oct 2026 15:24:55 +0800 Subject: [PATCH 2/2] fix(metadata): keep nullInt as permissive as database/sql The NULL-tolerant scan targets must not be stricter than the plain destinations they replace, or they would introduce new failures on the other databases sharing this information_schema reader (MySQL, SQL Server, ...), which never had this problem. database/sql converts into *int by rendering the driver value with asString and parsing the result, so it accepts numeric-as-text, bool and every integer/unsigned/float kind. nullInt only handled int64, int32, float64 and []byte, so a driver returning a value as, say, a string or uint64 would now fail where it previously worked. Replicate asString (strconv + reflect) and parse from that, matching the standard library exactly while still absorbing NULL. nullStr and nullBool are unaffected: database/sql's *string branch also accepts only string and []byte, which is what they already did. Adds nullscan_test.go asserting the targets accept exactly the value types database/sql converts, and that NULL is absorbed. Note the test cannot run without a working Docker daemon: TestMain in this package unconditionally starts database containers. --- .../metadata/informationschema/metadata.go | 63 +++++-- .../informationschema/nullscan_test.go | 173 ++++++++++++++++++ 2 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 drivers/metadata/informationschema/nullscan_test.go diff --git a/drivers/metadata/informationschema/metadata.go b/drivers/metadata/informationschema/metadata.go index 60d1fc6ea6c..28760834793 100644 --- a/drivers/metadata/informationschema/metadata.go +++ b/drivers/metadata/informationschema/metadata.go @@ -6,6 +6,7 @@ package informationschema import ( "database/sql" "fmt" + "reflect" "strconv" "strings" @@ -16,10 +17,10 @@ import ( // 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, '') +// 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, '') +// 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. @@ -39,27 +40,55 @@ func (n nullStr) Scan(v any) error { 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 { - switch t := v.(type) { - case nil: + if v == nil { *n.p = 0 - case int64: - *n.p = int(t) - case int32: - *n.p = int(t) - case float64: - *n.p = int(t) - case []byte: - i, err := strconv.Atoi(string(t)) - if err != nil { - return err - } - *n.p = i - default: + 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 } 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) + } + } +}