Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions dsn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -96,6 +97,7 @@ func NewConfig() *Config {
Logger: defaultLogger,
AllowNativePasswords: true,
CheckConnLiveness: true,
tinyInt1IsBool: true,
Comment thread
methane marked this conversation as resolved.
}
return cfg
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Comment thread
methane marked this conversation as resolved.
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)
Expand Down
46 changes: 44 additions & 2 deletions rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package mysql

import (
"database/sql"
"database/sql/driver"
"io"
"math"
Expand Down Expand Up @@ -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
Comment thread
methane marked this conversation as resolved.
}

func (rows *mysqlRows) ColumnTypeDatabaseTypeName(i int) string {
if rows.tinyInt1IsBool(i) {
return "BOOLEAN"
}
return rows.rs.columns[i].typeDatabaseName()
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
133 changes: 133 additions & 0 deletions tinyint1_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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)")

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)},
}
for row := 0; 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 !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)
}

stmt, err := dbt.db.Prepare("SELECT b, bn, n, u FROM " + tbl + " WHERE id = ?")
if err != nil {
dbt.Fatal(err)
}
defer stmt.Close()

var got [4]any
if err := stmt.QueryRow(3).Scan(&got[0], &got[1], &got[2], &got[3]); err != nil {
dbt.Fatal(err)
}
if !reflect.DeepEqual(got, want[2]) {
dbt.Errorf("prepared statement row = %#v; want %#v", got, want[2])
}
})
}

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)")

var got any
if err := dbt.db.QueryRow("SELECT b FROM " + tbl).Scan(&got); err != nil {
dbt.Fatal(err)
}
if got != int64(2) {
dbt.Fatalf("Scan(&any) = %#v; want int64(2)", got)
}
})
}
Loading