diff --git a/README.md b/README.md index ccbe6d078..45376e032 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,18 @@ Default: 0 > [!NOTE] > `time.Time` arguments are sent with up to nanosecond precision, so a value from `time.Now()` usually has more fractional-second digits than a `DATETIME(N)` or `TIMESTAMP(N)` column stores. On MariaDB, comparing such a value against an indexed column can prevent an index range scan, turning it into a full index scan. Truncating to the column's precision (`1us` for `DATETIME(6)`) avoids this. Only arguments sent to the server are truncated; values read from the server are not affected. +##### `tinyInt1IsBool` + +``` +Type: bool +Valid Values: true, false +Default: true +``` + +When `tinyInt1IsBool=true`, signed `TINYINT(1)` columns are treated as boolean values. Zero is returned as `false`, and non-zero values are returned as `true`. Their database type name is reported as `BOOLEAN`, and their scan type is `bool` for non-nullable columns or `sql.NullBool` for nullable columns. + +Unsigned and `ZEROFILL` columns are not converted. Set `tinyInt1IsBool=false` to preserve the numeric `TINYINT` behavior. + ##### `maxAllowedPacket` ``` Type: decimal number diff --git a/boolean_test.go b/boolean_test.go new file mode 100644 index 000000000..4ccd06c45 --- /dev/null +++ b/boolean_test.go @@ -0,0 +1,151 @@ +// Go MySQL Driver - A MySQL-Driver for Go's database/sql package +// +// Copyright 2026 The Go-MySQL-Driver Authors. All rights reserved. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at http://mozilla.org/MPL/2.0/. + +package mysql + +import ( + "database/sql" + "reflect" + "strings" + "testing" +) + +func TestTinyInt1IsBoolConfig(t *testing.T) { + cfg := NewConfig() + if !cfg.tinyInt1IsBool { + t.Fatal("tinyInt1IsBool should be enabled by default") + } + if got := cfg.FormatDSN(); strings.Contains(got, "tinyInt1IsBool") { + t.Fatalf("FormatDSN() = %q; default option should be omitted", got) + } + + if err := cfg.Apply(TinyInt1IsBool(false)); err != nil { + t.Fatal(err) + } + if cfg.tinyInt1IsBool { + t.Fatal("TinyInt1IsBool(false) did not disable the option") + } + if got := cfg.FormatDSN(); !strings.Contains(got, "tinyInt1IsBool=false") { + t.Fatalf("FormatDSN() = %q; want tinyInt1IsBool=false", got) + } + + cfg, err := ParseDSN("/?tinyInt1IsBool=false") + if err != nil { + t.Fatal(err) + } + if cfg.tinyInt1IsBool { + t.Fatal("ParseDSN did not disable tinyInt1IsBool") + } + + if _, err := ParseDSN("/?tinyInt1IsBool=invalid"); err == nil { + t.Fatal("ParseDSN accepted invalid tinyInt1IsBool value") + } +} + +func TestTinyInt1IsBool(t *testing.T) { + runTestsParallel(t, dsn, func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (" + + "id INT PRIMARY KEY, " + + "b TINYINT(1) NOT NULL, " + + "bn TINYINT(1), " + + "n TINYINT(2) NOT NULL, " + + "u TINYINT(1) UNSIGNED NOT NULL)") + dbt.mustExec("INSERT INTO " + tbl + " VALUES " + + "(1, 0, NULL, 2, 1), " + + "(2, 1, 0, 2, 1), " + + "(3, 2, -1, 2, 1), " + + "(4, 0, 0, 2, 1)") + + rows := dbt.mustQuery("SELECT b, bn, n, u FROM " + tbl + " ORDER BY id") + defer rows.Close() + + columnTypes, err := rows.ColumnTypes() + if err != nil { + dbt.Fatal(err) + } + wantDatabaseTypes := []string{"BOOLEAN", "BOOLEAN", "TINYINT", "UNSIGNED TINYINT"} + wantScanTypes := []reflect.Type{ + reflect.TypeFor[bool](), + reflect.TypeFor[sql.NullBool](), + scanTypeInt8, + scanTypeUint8, + } + for i, columnType := range columnTypes { + if got := columnType.DatabaseTypeName(); got != wantDatabaseTypes[i] { + dbt.Errorf("column %d DatabaseTypeName() = %q; want %q", i, got, wantDatabaseTypes[i]) + } + if got := columnType.ScanType(); got != wantScanTypes[i] { + dbt.Errorf("column %d ScanType() = %v; want %v", i, got, wantScanTypes[i]) + } + } + + want := [][4]any{ + {false, nil, int64(2), int64(1)}, + {true, false, int64(2), int64(1)}, + {true, true, int64(2), int64(1)}, + {false, false, int64(2), int64(1)}, + } + row := 0 + for ; rows.Next(); row++ { + var got [4]any + if err := rows.Scan(&got[0], &got[1], &got[2], &got[3]); err != nil { + dbt.Fatal(err) + } + if row >= len(want) { + dbt.Errorf("unexpected row %d = %#v", row, got) + continue + } + if !reflect.DeepEqual(got, want[row]) { + dbt.Errorf("row %d = %#v; want %#v", row, got, want[row]) + } + } + if err := rows.Err(); err != nil { + dbt.Fatal(err) + } + if row != len(want) { + dbt.Errorf("got %d rows; want %d", row, len(want)) + } + + stmt, err := dbt.db.Prepare("SELECT b, bn, n, u FROM " + tbl + " WHERE id = ?") + if err != nil { + dbt.Fatal(err) + } + defer stmt.Close() + + for _, id := range []int{3, 4} { + var got [4]any + if err := stmt.QueryRow(id).Scan(&got[0], &got[1], &got[2], &got[3]); err != nil { + dbt.Fatal(err) + } + if !reflect.DeepEqual(got, want[id-1]) { + dbt.Errorf("prepared statement row %d = %#v; want %#v", id, got, want[id-1]) + } + } + }) +} + +func TestTinyInt1IsBoolDisabled(t *testing.T) { + runTestsParallel(t, dsn+"&tinyInt1IsBool=false", func(dbt *DBTest, tbl string) { + dbt.mustExec("CREATE TABLE " + tbl + " (b TINYINT(1) NOT NULL)") + dbt.mustExec("INSERT INTO " + tbl + " VALUES (2)") + + stmt, err := dbt.db.Prepare("SELECT b FROM " + tbl + " WHERE b = ?") + if err != nil { + dbt.Fatal(err) + } + defer stmt.Close() + + var got any + if err := stmt.QueryRow(2).Scan(&got); err != nil { + dbt.Fatal(err) + } + if got != int64(2) { + dbt.Fatalf("Scan(&any) = %#v; want int64(2)", got) + } + }) +} diff --git a/driver_test.go b/driver_test.go index 761236f54..03486859c 100644 --- a/driver_test.go +++ b/driver_test.go @@ -421,8 +421,8 @@ func TestNumbersToAny(t *testing.T) { if err != nil { dbt.Fatal(err) } - if b.(int64) != 1 { - dbt.Errorf("b != 1") + if b != true { + dbt.Errorf("b = %#v; want true", b) } if i8.(int64) != 127 { dbt.Errorf("i8 != 127") @@ -3053,6 +3053,9 @@ func TestRowsColumnTypes(t *testing.T) { ni0 := sql.NullInt64{Int64: 0, Valid: true} ni1 := sql.NullInt64{Int64: 1, Valid: true} ni42 := sql.NullInt64{Int64: 42, Valid: true} + nbNULL := sql.NullBool{Bool: false, Valid: false} + nb0 := sql.NullBool{Bool: false, Valid: true} + nb1 := sql.NullBool{Bool: true, Valid: true} nfNULL := sql.NullFloat64{Float64: 0.0, Valid: false} nf0 := sql.NullFloat64{Float64: 0.0, Valid: true} nf1337 := sql.NullFloat64{Float64: 13.37, Valid: true} @@ -3088,8 +3091,8 @@ func TestRowsColumnTypes(t *testing.T) { valuesOut [3]any }{ {"bit8null", "BIT(8)", "BIT", scanTypeBytes, true, 0, 0, [3]string{"0x0", "NULL", "0x42"}, [3]any{bx0, bNULL, bx42}}, - {"boolnull", "BOOL", "TINYINT", scanTypeNullInt, true, 0, 0, [3]string{"NULL", "true", "0"}, [3]any{niNULL, ni1, ni0}}, - {"bool", "BOOL NOT NULL", "TINYINT", scanTypeInt8, false, 0, 0, [3]string{"1", "0", "FALSE"}, [3]any{int8(1), int8(0), int8(0)}}, + {"boolnull", "BOOL", "BOOLEAN", reflect.TypeFor[sql.NullBool](), true, 0, 0, [3]string{"NULL", "true", "0"}, [3]any{nbNULL, nb1, nb0}}, + {"bool", "BOOL NOT NULL", "BOOLEAN", reflect.TypeFor[bool](), false, 0, 0, [3]string{"1", "0", "FALSE"}, [3]any{true, false, false}}, {"intnull", "INTEGER", "INT", scanTypeNullInt, true, 0, 0, [3]string{"0", "NULL", "42"}, [3]any{ni0, niNULL, ni42}}, {"smallint", "SMALLINT NOT NULL", "SMALLINT", scanTypeInt16, false, 0, 0, [3]string{"0", "-32768", "32767"}, [3]any{int16(0), int16(-32768), int16(32767)}}, {"smallintnull", "SMALLINT", "SMALLINT", scanTypeNullInt, true, 0, 0, [3]string{"0", "NULL", "42"}, [3]any{ni0, niNULL, ni42}}, diff --git a/dsn.go b/dsn.go index 41463a503..74dc6d901 100644 --- a/dsn.go +++ b/dsn.go @@ -76,7 +76,8 @@ type Config struct { // unexported fields. new options should be come here. // boolean first. alphabetical order. - compress bool // Enable zlib compression + compress bool // Enable zlib compression + tinyInt1IsBool bool // Treat signed TINYINT(1) as boolean beforeConnect func(context.Context, *Config) error // Invoked before a connection is established pubKey *rsa.PublicKey // Server public key @@ -96,6 +97,7 @@ func NewConfig() *Config { Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, + tinyInt1IsBool: true, } return cfg } @@ -136,6 +138,14 @@ func EnableCompression(yes bool) Option { } } +// TinyInt1IsBool controls whether signed TINYINT(1) columns are treated as boolean. +func TinyInt1IsBool(yes bool) Option { + return func(cfg *Config) error { + cfg.tinyInt1IsBool = yes + return nil + } +} + // Charset sets the connection charset and collation. // // charset is the connection charset. @@ -355,6 +365,10 @@ func (cfg *Config) FormatDSN() string { writeDSNParam(&buf, &hasParam, "timeTruncate", cfg.timeTruncate.String()) } + if !cfg.tinyInt1IsBool { + writeDSNParam(&buf, &hasParam, "tinyInt1IsBool", "false") + } + if cfg.ReadTimeout > 0 { writeDSNParam(&buf, &hasParam, "readTimeout", cfg.ReadTimeout.String()) } @@ -603,13 +617,6 @@ func parseDSNParams(cfg *Config, params string) (err error) { return errors.New("invalid bool value: " + value) } - // time.Time truncation - case "timeTruncate": - cfg.timeTruncate, err = time.ParseDuration(value) - if err != nil { - return fmt.Errorf("invalid timeTruncate value: %v, error: %w", value, err) - } - // I/O read Timeout case "readTimeout": cfg.ReadTimeout, err = time.ParseDuration(value) @@ -644,6 +651,21 @@ func parseDSNParams(cfg *Config, params string) (err error) { return } + // time.Time truncation + case "timeTruncate": + cfg.timeTruncate, err = time.ParseDuration(value) + if err != nil { + return fmt.Errorf("invalid timeTruncate value: %v, error: %w", value, err) + } + + // Treat TINYINT(1) as boolean + case "tinyInt1IsBool": + var isBool bool + cfg.tinyInt1IsBool, isBool = readBool(value) + if !isBool { + return errors.New("invalid bool value: " + value) + } + // TLS-Encryption case "tls": boolValue, isBool := readBool(value) diff --git a/dsn_test.go b/dsn_test.go index 0c8ac7a04..131f8a981 100644 --- a/dsn_test.go +++ b/dsn_test.go @@ -22,64 +22,64 @@ var testDSNs = []struct { out *Config }{{ "username:password@protocol(address)/dbname?param=value", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, ColumnsWithAlias: true}, }, { "username:password@protocol(address)/dbname?param=value&columnsWithAlias=true&multiStatements=true", - &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ColumnsWithAlias: true, MultiStatements: true}, + &Config{User: "username", Passwd: "password", Net: "protocol", Addr: "address", DBName: "dbname", Params: map[string]string{"param": "value"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, ColumnsWithAlias: true, MultiStatements: true}, }, { "user@unix(/path/to/socket)/dbname?charset=utf8", - &Config{User: "user", Net: "unix", Addr: "/path/to/socket", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{User: "user", Net: "unix", Addr: "/path/to/socket", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "user:password@tcp(localhost:5555)/dbname?charset=utf8&tls=true", - &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "true"}, + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, TLSConfig: "true"}, }, { "user:password@tcp(localhost:5555)/dbname?charset=utf8mb4,utf8&tls=skip-verify", - &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8mb4", "utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, TLSConfig: "skip-verify"}, + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "localhost:5555", DBName: "dbname", charsets: []string{"utf8mb4", "utf8"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, TLSConfig: "skip-verify"}, }, { "user:password@/dbname?loc=UTC&timeout=30s&readTimeout=1s&writeTimeout=1s&allowAllFiles=1&clientFoundRows=true&allowOldPasswords=TRUE&collation=utf8mb4_unicode_ci&maxAllowedPacket=16777216&tls=false&allowCleartextPasswords=true&parseTime=true&rejectReadOnly=true", - &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Collation: "utf8mb4_unicode_ci", Loc: time.UTC, TLSConfig: "false", AllowCleartextPasswords: true, AllowNativePasswords: true, Timeout: 30 * time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, Logger: defaultLogger, AllowAllFiles: true, AllowOldPasswords: true, CheckConnLiveness: true, ClientFoundRows: true, MaxAllowedPacket: 16777216, ParseTime: true, RejectReadOnly: true}, + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Collation: "utf8mb4_unicode_ci", Loc: time.UTC, TLSConfig: "false", AllowCleartextPasswords: true, AllowNativePasswords: true, Timeout: 30 * time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, Logger: defaultLogger, AllowAllFiles: true, AllowOldPasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, ClientFoundRows: true, MaxAllowedPacket: 16777216, ParseTime: true, RejectReadOnly: true}, }, { "user:password@/dbname?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0&allowFallbackToPlaintext=true", - &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: 0, Logger: defaultLogger, AllowFallbackToPlaintext: true, AllowNativePasswords: false, CheckConnLiveness: false}, + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: 0, Logger: defaultLogger, AllowFallbackToPlaintext: true, AllowNativePasswords: false, CheckConnLiveness: false, tinyInt1IsBool: true}, }, { "user:p@ss(word)@tcp([de:ad:be:ef::ca:fe]:80)/dbname?loc=Local", - &Config{User: "user", Passwd: "p@ss(word)", Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:80", DBName: "dbname", Loc: time.Local, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{User: "user", Passwd: "p@ss(word)", Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:80", DBName: "dbname", Loc: time.Local, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "/dbname", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "/dbname%2Fwithslash", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname/withslash", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname/withslash", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "@/", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "/", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "user:p@/ssword@/", - &Config{User: "user", Passwd: "p@/ssword", Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{User: "user", Passwd: "p@/ssword", Net: "tcp", Addr: "127.0.0.1:3306", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "unix/?arg=%2Fsome%2Fpath.ext", - &Config{Net: "unix", Addr: "/tmp/mysql.sock", Params: map[string]string{"arg": "/some/path.ext"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "unix", Addr: "/tmp/mysql.sock", Params: map[string]string{"arg": "/some/path.ext"}, Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "tcp(127.0.0.1)/dbname", - &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "tcp(de:ad:be:ef::ca:fe)/dbname", - &Config{Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true}, + &Config{Net: "tcp", Addr: "[de:ad:be:ef::ca:fe]:3306", DBName: "dbname", Loc: time.UTC, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true}, }, { "user:password@/dbname?loc=UTC&timeout=30s&parseTime=true&timeTruncate=1h", - &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, Timeout: 30 * time.Second, ParseTime: true, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, timeTruncate: time.Hour}, + &Config{User: "user", Passwd: "password", Net: "tcp", Addr: "127.0.0.1:3306", DBName: "dbname", Loc: time.UTC, Timeout: 30 * time.Second, ParseTime: true, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, timeTruncate: time.Hour}, }, { "foo:bar@tcp(192.168.1.50:3307)/baz?timeout=10s&connectionAttributes=program_name:MySQLGoDriver%2FTest,program_version:1.2.3", - &Config{User: "foo", Passwd: "bar", Net: "tcp", Addr: "192.168.1.50:3307", DBName: "baz", Loc: time.UTC, Timeout: 10 * time.Second, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, ConnectionAttributes: "program_name:MySQLGoDriver/Test,program_version:1.2.3"}, + &Config{User: "foo", Passwd: "bar", Net: "tcp", Addr: "192.168.1.50:3307", DBName: "baz", Loc: time.UTC, Timeout: 10 * time.Second, MaxAllowedPacket: defaultMaxAllowedPacket, Logger: defaultLogger, AllowNativePasswords: true, CheckConnLiveness: true, tinyInt1IsBool: true, ConnectionAttributes: "program_name:MySQLGoDriver/Test,program_version:1.2.3"}, }, } diff --git a/rows.go b/rows.go index 190e75f9b..f5067123e 100644 --- a/rows.go +++ b/rows.go @@ -9,6 +9,7 @@ package mysql import ( + "database/sql" "database/sql/driver" "io" "math" @@ -59,7 +60,20 @@ func (rows *mysqlRows) Columns() []string { return columns } +func (rows *mysqlRows) tinyInt1IsBool(i int) bool { + if rows.mc == nil || !rows.mc.cfg.tinyInt1IsBool { + return false + } + column := rows.rs.columns[i] + return column.fieldType == fieldTypeTiny && + column.length == 1 && + column.flags&(flagUnsigned|flagZeroFill) == 0 +} + func (rows *mysqlRows) ColumnTypeDatabaseTypeName(i int) string { + if rows.tinyInt1IsBool(i) { + return "BOOLEAN" + } return rows.rs.columns[i].typeDatabaseName() } @@ -94,9 +108,29 @@ func (rows *mysqlRows) ColumnTypePrecisionScale(i int) (int64, int64, bool) { } func (rows *mysqlRows) ColumnTypeScanType(i int) reflect.Type { + if rows.tinyInt1IsBool(i) { + if rows.rs.columns[i].flags&flagNotNULL != 0 { + return reflect.TypeFor[bool]() + } + return reflect.TypeFor[sql.NullBool]() + } return rows.rs.columns[i].scanType() } +func (rows *mysqlRows) convertTinyInt1ToBool(dest []driver.Value) { + if rows.mc == nil || !rows.mc.cfg.tinyInt1IsBool { + return + } + for i, v := range dest { + if !rows.tinyInt1IsBool(i) || v == nil { + continue + } + if n, ok := v.(int64); ok { + dest[i] = n != 0 + } + } +} + func (rows *mysqlRows) Close() (err error) { if f := rows.finish; f != nil { f() @@ -197,7 +231,11 @@ func (rows *binaryRows) Next(dest []driver.Value) error { } // Fetch next row from stream - return rows.readRow(dest) + if err := rows.readRow(dest); err != nil { + return err + } + rows.convertTinyInt1ToBool(dest) + return nil } return io.EOF } @@ -219,7 +257,11 @@ func (rows *textRows) Next(dest []driver.Value) error { } // Fetch next row from stream - return rows.readRow(dest) + if err := rows.readRow(dest); err != nil { + return err + } + rows.convertTinyInt1ToBool(dest) + return nil } return io.EOF }