From 0d56e3e0a9899f0bc50b9608845c9dfd76e21853 Mon Sep 17 00:00:00 2001 From: Matvey Gubanikhin Date: Thu, 5 Nov 2026 13:52:11 +0300 Subject: [PATCH 1/4] Support the new database/sql scanning mechanism in Go 1.27. --- .github/workflows/go.yml | 10 +- fbtype/scan.go | 3 + stdlib/sql.go | 24 +- stdlib/sql_go1.27.go | 153 +++++++++++++ stdlib/sql_go1.27_test.go | 452 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 stdlib/sql_go1.27.go create mode 100644 stdlib/sql_go1.27_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 59b1474..ad9a6bd 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -42,7 +42,7 @@ jobs: lint: runs-on: self-hosted container: - image: golangci/golangci-lint:v2.12.2 + image: golangci/golangci-lint:v2.15.0 steps: - name: Checkout uses: actions/checkout@v7 @@ -124,7 +124,7 @@ jobs: test_fb: runs-on: self-hosted container: - image: golang:1.26 + image: golang:${{ matrix.GO_VERSION || '1.26' }} strategy: matrix: include: @@ -134,8 +134,12 @@ jobs: FB_DOWNLOAD_URL: http://minio.red-soft.biz/3rdparty/firebird/Firebird-${FB40_VERSION}-linux-x64.tar.gz - FB_VERSION: ${FB50_VERSION} FB_DOWNLOAD_URL: http://minio.red-soft.biz/3rdparty/firebird/Firebird-${FB50_VERSION}-linux-x64.tar.gz + - FB_VERSION: ${FB50_VERSION} + FB_DOWNLOAD_URL: http://minio.red-soft.biz/3rdparty/firebird/Firebird-${FB50_VERSION}-linux-x64.tar.gz + GO_VERSION: "1.27" + REPORT_SUFFIX: _go1.27 env: - REPORT: firebird_${{matrix.FB_VERSION}} + REPORT: firebird_${{matrix.FB_VERSION}}${{matrix.REPORT_SUFFIX}} steps: - run: echo ${REPORT} diff --git a/fbtype/scan.go b/fbtype/scan.go index 50cdc75..0a601af 100644 --- a/fbtype/scan.go +++ b/fbtype/scan.go @@ -685,6 +685,9 @@ func codecScan(codec Codec, m *Map, typeMetadata TypeMetadata, src []byte, dst a // If v already implements sql.Scanner it is returned unchanged. Otherwise v // must be a target supported by the Firebird array scan path, such as *[]T, // *Array[T], *FlatArray[T], or a compatible array/slice target. +// +// As of Go 1.27, [database/sql] calls the driver directly to scan columns when using fbx's stdlib package, so this is +// no longer necessary. func (m *Map) SQLScanner(v any) sql.Scanner { if s, ok := v.(sql.Scanner); ok { return s diff --git a/stdlib/sql.go b/stdlib/sql.go index c690c62..303870c 100644 --- a/stdlib/sql.go +++ b/stdlib/sql.go @@ -75,7 +75,17 @@ // // # Firebird-specific data types // -// The [fbtype] package provides Firebird-specific types and codecs. +// As of Go 1.27, [database/sql] allows drivers to implement their own scanning logic by implementing the +// driver.RowsColumnScanner interface. This allows Firebird types such as arrays to be scanned directly into Go +// values such as slices. +// +// input := []int64{1, 2, 3} +// _, err := db.Exec("insert into array_example(bigint_array) values(?)", input) +// +// var output []int64 +// err = db.QueryRow("select bigint_array from array_example").Scan(&output) +// +// In older versions of Go, the [fbtype] package provides Firebird-specific types and codecs. // [fbtype.Map.SQLScanner] is primarily used to scan Firebird arrays through // [database/sql]: // @@ -787,6 +797,7 @@ type Rows struct { skipNextMore bool columnNames []string + blobValues [][]byte } // Columns implements [driver.Rows]. @@ -970,8 +981,9 @@ func scanBlobValues(rows fbx.Rows, blobColumns []bool) ([][]byte, error) { return values, nil } -// Next implements [driver.Rows]. -func (r *Rows) Next(dest []driver.Value) (err error) { +// initValueFuncs prepares the [database/sql] representation of each column. Both +// [Next] and [ScanColumn] use these conversions so their [driver.Values] agree. +func (r *Rows) initValueFuncs() { m := r.conn.conn.TypeMap() fieldDescriptions := r.rows.FieldDescriptions() @@ -1131,8 +1143,14 @@ func (r *Rows) Next(dest []driver.Value) (err error) { } } } +} + +// Next implements [driver.Rows]. +func (r *Rows) Next(dest []driver.Value) (err error) { + r.initValueFuncs() var more bool + fieldDescriptions := r.rows.FieldDescriptions() if r.skipNext { more = r.skipNextMore r.skipNext = false diff --git a/stdlib/sql_go1.27.go b/stdlib/sql_go1.27.go new file mode 100644 index 0000000..2ef7327 --- /dev/null +++ b/stdlib/sql_go1.27.go @@ -0,0 +1,153 @@ +//go:build go1.27 + +package stdlib + +import ( + "database/sql" + "database/sql/driver" + "errors" + "io" + "reflect" + "time" + + "github.com/firebirdsql/fbx/fbtype" +) + +// [Rows] implements [driver.RowsColumnScanner] as of Go 1.27. +var _ driver.RowsColumnScanner = (*Rows)(nil) + +// NextRow implements the [driver.RowsColumnScanner] interface. It advances to the +// next row of data and returns [io.EOF] when there are no more rows. +func (r *Rows) NextRow() error { + var more bool + if r.skipNext { + more = r.skipNextMore + r.skipNext = false + } else { + more = r.rows.Next() + } + + if !more { + if err := r.rows.Err(); err != nil { + return err + } + return io.EOF + } + + r.initValueFuncs() + + var err error + if r.hasBlobCols { + r.blobValues, err = scanBlobValues(r.rows, r.blobColumns) + if err != nil { + return err + } + } + + return nil +} + +// ScanColumn implements the [driver.RowsColumnScanner] interface. It preserves +// [database/sql] conversions for scalar destinations and [sql.Scanner] implementations. +// Other destinations, such as Go slices, fbtype.Array, are +// scanned directly using the fbx type map. +func (r *Rows) ScanColumn(scanCtx driver.ScanContext, index int, dest any) error { + if dest == nil { + return errors.New("destination not a pointer") + } + rv := reflect.ValueOf(dest) + if rv.Kind() == reflect.Pointer && rv.IsNil() { + return errors.New("destination pointer is nil") + } + + fd := r.rows.FieldDescriptions()[index] + typeMetadata := fbtype.NewTypeMetadata( + fd.Type, + fd.Subtype, + fd.Length, + fd.Scale, + ) + + arr := fd.ArrayData + if arr != nil { + arrayData, err := fbtype.NewArrayData(arr.TypeId, arr.SubType, arr.Length, arr.Scale, arr.Dimensions) + if err != nil { + return err + } + typeMetadata.SetArrayMetadata(arrayData) + } + src := r.rows.RawValues()[index] + + if fd.IsBlob() { + if src == nil { + return sql.ConvertAssign(scanCtx, dest, nil) + } + blobValue, err := buildBlobValue(r.blobValues[index], &r.rows.FieldDescriptions()[index]) + if err != nil { + return err + } + + return sql.ConvertAssign(scanCtx, dest, blobValue) + } + + if isSQLScanDestination(rv.Type()) { + var value driver.Value + if src != nil { + var err error + value, err = r.valueFuncs[index](src) + if err != nil { + return err + } + } + return sql.ConvertAssign(scanCtx, dest, value) + } + + m := r.conn.conn.TypeMap() + + return m.Scan(typeMetadata, src, dest) +} + +// isSQLScanDestination includes named scalar types and nullable pointers to +// [database/sql] destinations. Select the conversion before scanning: retrying a +// failed scan could call user code twice or discard its error. +func isSQLScanDestination(t reflect.Type) bool { + var namedPointers map[reflect.Type]bool + for { + if t.Implements(reflect.TypeFor[sql.Scanner]()) { + return true + } + // These interfaces distinguish native byte slices, such as + // FlatArray[byte] and PreallocBytes, from ordinary []byte destinations. + if t.Implements(reflect.TypeFor[fbtype.ArraySetter]()) || t.Implements(reflect.TypeFor[fbtype.BytesScanner]()) { + return false + } + if t.Kind() != reflect.Pointer { + break + } + // Only defined pointer types can form a cycle without reaching a + // non-pointer type. Leave those to fbx's bounded scan planning. + if t.Name() != "" { + if namedPointers[t] { + return false + } + if namedPointers == nil { + namedPointers = make(map[reflect.Type]bool) + } + namedPointers[t] = true + } + t = t.Elem() + } + + switch t.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, reflect.String, reflect.Interface: + return true + case reflect.Slice: + return t.Elem().Kind() == reflect.Uint8 + case reflect.Struct: + return t.ConvertibleTo(reflect.TypeFor[time.Time]()) + default: + return false + } +} diff --git a/stdlib/sql_go1.27_test.go b/stdlib/sql_go1.27_test.go new file mode 100644 index 0000000..7392c68 --- /dev/null +++ b/stdlib/sql_go1.27_test.go @@ -0,0 +1,452 @@ +//go:build go1.27 + +package stdlib_test + +import ( + "database/sql" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fbx "github.com/firebirdsql/fbx" + "github.com/firebirdsql/fbx/fbtype" + "github.com/firebirdsql/fbx/stdlib" +) + +func testWithKnownTypeQueryExecModes(t *testing.T, f func(t *testing.T, db *sql.DB)) { + for _, mode := range []fbx.QueryExecMode{ + fbx.QueryExecModeCacheStatement, + fbx.QueryExecModeExec, + } { + t.Run(mode.String(), + func(t *testing.T) { + config, err := fbx.ParseConfig(os.Getenv("FBX_TEST_DATABASE")) + require.NoError(t, err) + + config.DefaultQueryExecMode = mode + db := stdlib.OpenDB(*config) + defer func() { + err := db.Close() + require.NoError(t, err) + }() + + f(t, db) + + ensureDBValid(t, db) + }, + ) + } +} + +func TestScanNonEmptyBlob(t *testing.T) { + db := openDB(t) + + rows, err := db.Query("select cast('blob' as blob sub_type text) from rdb$database") + require.NoError(t, err) + require.True(t, rows.Next()) + var blob stdlib.Blob + err = rows.Scan(&blob) + // Register cleanup after Scan so a panic inside database/sql does not hang in Close. + defer closeDB(t, db) + defer closeResource(t, rows) + require.NoError(t, err) + defer closeResource(t, &blob) + + text, err := blob.Text() + require.NoError(t, err) + require.Equal(t, "blob", text) +} + +func TestScanBlobIntoInterface(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + tests := []struct { + name string + query string + subType int32 + }{ + {name: "text", query: "select cast('blob' as blob sub_type text) from rdb$database", subType: 1}, + {name: "binary", query: "select cast('blob' as blob sub_type binary) from rdb$database", subType: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var actual any = "previous value" + err := db.QueryRow(tt.query).Scan(&actual) + require.NoError(t, err) + blob, ok := actual.(stdlib.Blob) + require.True(t, ok) + defer closeResource(t, &blob) + require.Equal(t, tt.subType, blob.SubType) + + data, err := blob.Bytes() + require.NoError(t, err) + require.Equal(t, []byte("blob"), data) + }) + } + }) +} + +func TestScanBlobNullClearsDestination(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + var actual any = "previous value" + err := db.QueryRow("select cast(null as blob sub_type text) from rdb$database").Scan(&actual) + require.NoError(t, err) + require.Nil(t, actual) + + var blob stdlib.Blob + err = db.QueryRow("select cast('blob' as blob sub_type text) from rdb$database").Scan(&blob) + require.NoError(t, err) + closeResource(t, &blob) + + err = db.QueryRow("select cast(null as blob sub_type text) from rdb$database").Scan(&blob) + require.NoError(t, err) + require.Equal(t, stdlib.Blob{}, blob) + + err = db.QueryRow("select cast('' as blob sub_type text) from rdb$database").Scan(&actual) + require.NoError(t, err) + emptyBlob, ok := actual.(stdlib.Blob) + require.True(t, ok) + defer closeResource(t, &emptyBlob) + data, err := emptyBlob.Bytes() + require.NoError(t, err) + require.Empty(t, data) + }) +} + +func TestScanBlobPreservesScannerError(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, query := range []string{ + "select cast('blob' as blob sub_type text) from rdb$database", + "select cast('blob' as blob sub_type binary) from rdb$database", + "select cast(null as blob sub_type text) from rdb$database", + } { + t.Run(query, func(t *testing.T) { + errRejected := errors.New("blob rejected by scanner") + s := &scanOnce{err: errRejected} + err := db.QueryRow(query).Scan(s) + require.ErrorIs(t, err, errRejected) + require.Equal(t, 1, s.calls) + }) + } + }) +} + +func TestScanBlobRejectsIncompatibleDestination(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, query := range []string{ + "select cast('blob' as blob sub_type text) from rdb$database", + "select cast(null as blob sub_type text) from rdb$database", + } { + t.Run(query, func(t *testing.T) { + actual := 42 + err := db.QueryRow(query).Scan(&actual) + require.Error(t, err) + require.Equal(t, 42, actual) + }) + } + }) +} + +func TestScanBlobMixedColumns(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + var number int + var first, second stdlib.Blob + var nullBlob any = "previous value" + err := db.QueryRow(`select cast(42 as integer), + cast('first' as blob sub_type text), + cast(null as blob sub_type text), + cast('second' as blob sub_type text) + from rdb$database`).Scan(&number, &first, &nullBlob, &second) + require.NoError(t, err) + defer closeResource(t, &first) + defer closeResource(t, &second) + require.Equal(t, 42, number) + require.Nil(t, nullBlob) + + text, err := first.Text() + require.NoError(t, err) + require.Equal(t, "first", text) + text, err = second.Text() + require.NoError(t, err) + require.Equal(t, "second", text) + }) +} + +func TestGoArray(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + _, err := db.Exec("recreate table go_array_test (id integer primary key, names varchar(10)[2])") + require.NoError(t, err) + + expected := []string{"John", "Jane"} + _, err = db.Exec("insert into go_array_test values (?, ?)", 1, expected) + require.NoError(t, err) + + var names []string + err = db.QueryRow("select names from go_array_test where id = ?", 1).Scan(&names) + require.NoError(t, err) + require.Equal(t, expected, names) + + _, err = db.Exec("insert into go_array_test values (?, ?)", 2, names) + require.NoError(t, err) + names = nil + err = db.QueryRow("select names from go_array_test where id = ?", 2).Scan(&names) + require.NoError(t, err) + require.Equal(t, expected, names) + + _, err = db.Exec("insert into go_array_test values (?, ?)", 3, nil) + require.NoError(t, err) + err = db.QueryRow("select names from go_array_test where id = ?", 3).Scan(&names) + require.NoError(t, err) + require.Nil(t, names) + }) +} + +func TestFbTypeFlatArray(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + _, err := db.Exec("recreate table go_flat_array_test (id integer primary key, names varchar(10)[2])") + require.NoError(t, err) + + expected := fbtype.FlatArray[string]{"John", "Jane"} + _, err = db.Exec("insert into go_flat_array_test values (?, ?)", 1, expected) + require.NoError(t, err) + + var names fbtype.FlatArray[string] + err = db.QueryRow("select names from go_flat_array_test where id = ?", 1).Scan(&names) + require.NoError(t, err) + require.Equal(t, expected, names) + + _, err = db.Exec("insert into go_flat_array_test values (?, ?)", 2, names) + require.NoError(t, err) + names = nil + err = db.QueryRow("select names from go_flat_array_test where id = ?", 2).Scan(&names) + require.NoError(t, err) + require.Equal(t, expected, names) + + _, err = db.Exec("insert into go_flat_array_test values (?, ?)", 3, nil) + require.NoError(t, err) + err = db.QueryRow("select names from go_flat_array_test where id = ?", 3).Scan(&names) + require.NoError(t, err) + require.Nil(t, names) + }) +} + +func TestFbTypeArray(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + _, err := db.Exec("recreate table go_matrix_test (id integer primary key, matrix bigint[2,3])") + require.NoError(t, err) + + expected := fbtype.Array[int64]{ + Elements: []int64{1, 2, 3, 4, 5, 6}, + Dims: []fbtype.ArrayDimension{ + {Length: 2, LowerBound: 1}, + {Length: 3, LowerBound: 1}, + }, + Valid: true, + } + _, err = db.Exec("insert into go_matrix_test values (?, ?)", 1, expected) + require.NoError(t, err) + + var matrix fbtype.Array[int64] + err = db.QueryRow("select matrix from go_matrix_test where id = ?", 1).Scan(&matrix) + require.NoError(t, err) + require.Equal(t, expected, matrix) + + _, err = db.Exec("insert into go_matrix_test values (?, ?)", 2, matrix) + require.NoError(t, err) + matrix = fbtype.Array[int64]{} + err = db.QueryRow("select matrix from go_matrix_test where id = ?", 2).Scan(&matrix) + require.NoError(t, err) + require.Equal(t, expected, matrix) + + _, err = db.Exec("insert into go_matrix_test values (?, ?)", 3, nil) + require.NoError(t, err) + err = db.QueryRow("select matrix from go_matrix_test where id = ?", 3).Scan(&matrix) + require.NoError(t, err) + require.Equal(t, fbtype.Array[int64]{}, matrix) + }) +} + +// Boolean stringification must agree across query execution modes. +func TestScanBoolToString(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + var s string + err := db.QueryRow("select true from rdb$database").Scan(&s) + require.NoError(t, err) + require.Equal(t, "true", s) + + err = db.QueryRow("select false from rdb$database").Scan(&s) + require.NoError(t, err) + require.Equal(t, "false", s) + }) +} + +func TestScanInterfacePreservesDatabaseSQLValues(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, tt := range []struct { + query string + want any + }{ + {"select cast(42 as smallint) from rdb$database", int64(42)}, + {"select cast(42 as integer) from rdb$database", int64(42)}, + {"select cast(42 as bigint) from rdb$database", int64(42)}, + {"select cast(1.5 as float) from rdb$database", float64(1.5)}, + {"select cast(1.25 as numeric(9, 2)) from rdb$database", "1.25"}, + {"select cast(x'39303037313939323534373430393933' as varchar(16) character set octets) from rdb$database", []byte("9007199254740993")}, + {"select cast(x'6e756c6c' as varchar(4) character set octets) from rdb$database", []byte("null")}, + {"select cast(null as integer) from rdb$database", nil}, + } { + t.Run(tt.query, func(t *testing.T) { + var got any + err := db.QueryRow(tt.query).Scan(&got) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + + var indirect *any + err = db.QueryRow(tt.query).Scan(&indirect) + require.NoError(t, err) + if tt.want == nil { + assert.Nil(t, indirect) + } else { + require.NotNil(t, indirect) + assert.Equal(t, tt.want, *indirect) + } + }) + } + }) +} + +func TestScanInterfacePreservesArray(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + _, err := db.Exec("recreate table go_interface_array_test (id integer primary key, numbers integer[2])") + require.NoError(t, err) + _, err = db.Exec("insert into go_interface_array_test values (?, ?)", 1, []int32{1, 2}) + require.NoError(t, err) + + var got any + err = db.QueryRow("select numbers from go_interface_array_test where id = ?", 1).Scan(&got) + require.NoError(t, err) + array, ok := got.(fbtype.ArraySQLScanner) + require.True(t, ok) + var actual []int32 + require.NoError(t, fbtype.NewMap().SQLScanner(&actual).Scan(array)) + require.Equal(t, []int32{1, 2}, actual) + + var indirect *any + err = db.QueryRow("select numbers from go_interface_array_test where id = ?", 1).Scan(&indirect) + require.NoError(t, err) + require.NotNil(t, indirect) + array, ok = (*indirect).(fbtype.ArraySQLScanner) + require.True(t, ok) + actual = nil + require.NoError(t, fbtype.NewMap().SQLScanner(&actual).Scan(array)) + require.Equal(t, []int32{1, 2}, actual) + }) +} + +func TestScanScalarPreservesDatabaseSQLConversions(t *testing.T) { + type namedString string + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, tt := range []struct{ query, want string }{ + {"select true from rdb$database", "true"}, + {"select cast('2026-09-07' as date) from rdb$database", "2026-09-07T00:00:00Z"}, + {"select cast(x'6162' as varchar(2) character set octets) from rdb$database", "ab"}, + } { + t.Run(tt.query, func(t *testing.T) { + var s string + err := db.QueryRow(tt.query).Scan(&s) + require.NoError(t, err) + assert.Equal(t, tt.want, s) + + var b []byte + err = db.QueryRow(tt.query).Scan(&b) + require.NoError(t, err) + assert.Equal(t, []byte(tt.want), b) + + var indirect *string + err = db.QueryRow(tt.query).Scan(&indirect) + require.NoError(t, err) + require.NotNil(t, indirect) + assert.Equal(t, tt.want, *indirect) + + rows, err := db.Query(tt.query) + require.NoError(t, err) + defer closeResource(t, rows) + require.True(t, rows.Next()) + var raw sql.RawBytes + err = rows.Scan(&raw) + require.NoError(t, err) + assert.Equal(t, sql.RawBytes(tt.want), raw) + require.NoError(t, rows.Close()) + }) + } + + var named namedString + err := db.QueryRow("select cast(x'6162' as varchar(2) character set octets) from rdb$database").Scan(&named) + require.NoError(t, err) + assert.Equal(t, namedString("ab"), named) + + var s string + err = db.QueryRow("select cast(null as varchar(10)) from rdb$database").Scan(&s) + require.Error(t, err) + }) +} + +type scanOnce struct { + calls int + err error +} + +func (s *scanOnce) Scan(any) error { + s.calls++ + if s.calls == 1 { + return s.err + } + return nil +} + +func TestScanPreservesScannerError(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, query := range []string{"select cast(42 as integer) from rdb$database", "select cast(x'31' as varchar(1) character set octets) from rdb$database", "select cast(null as integer) from rdb$database"} { + errRejected := errors.New("value rejected by scanner") + s := &scanOnce{err: errRejected} + err := db.QueryRow(query).Scan(s) + assert.ErrorIs(t, err, errRejected) + assert.Equal(t, 1, s.calls) + } + }) +} + +func TestScanRejectsNilDestination(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + for _, dest := range []any{nil, (*int64)(nil), (*string)(nil), (*[]int32)(nil), (*sql.NullString)(nil)} { + for _, query := range []string{"select cast(42 as integer) from rdb$database", "select cast(null as integer) from rdb$database"} { + err := db.QueryRow(query).Scan(dest) + require.Error(t, err) + } + } + }) +} + +func TestScanNativeByteSlices(t *testing.T) { + testWithAllQueryExecModes(t, func(t *testing.T, db *sql.DB) { + _, err := db.Exec("recreate table go_byte_array_test (id integer primary key, numbers smallint[3])") + require.NoError(t, err) + _, err = db.Exec("insert into go_byte_array_test values (?, ?)", 1, fbtype.FlatArray[byte]{1, 2, 3}) + require.NoError(t, err) + + var a fbtype.FlatArray[byte] + err = db.QueryRow("select numbers from go_byte_array_test where id = ?", 1).Scan(&a) + require.NoError(t, err) + assert.Equal(t, fbtype.FlatArray[byte]{1, 2, 3}, a) + + backing := make([]byte, 16) + b := fbtype.PreallocBytes(backing) + err = db.QueryRow("select cast(x'6162' as varchar(2) character set octets) from rdb$database").Scan(&b) + require.NoError(t, err) + assert.Equal(t, fbtype.PreallocBytes("ab"), b) + assert.Same(t, &backing[0], &b[0]) + }) +} From 22502bc6ded2a9bf42d8b01343626616cee6fe6d Mon Sep 17 00:00:00 2001 From: Matvey Gubanikhin Date: Mon, 9 Nov 2026 13:54:12 +0300 Subject: [PATCH 2/4] Fix TestConfigSetOptionsSlicesNotLinked on go 1.27. --- fbconn/auth_options_test.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fbconn/auth_options_test.go b/fbconn/auth_options_test.go index cbcaac4..72b21a1 100644 --- a/fbconn/auth_options_test.go +++ b/fbconn/auth_options_test.go @@ -3,7 +3,6 @@ package fbconn import ( "context" "fmt" - "reflect" "testing" "github.com/firebirdsql/fbx/fbproto/auth" @@ -86,27 +85,33 @@ func TestConfigCopyConstructorOptionsMapNotLinked(t *testing.T) { func TestConfigSetOptionsSlicesNotLinked(t *testing.T) { registerPlugin() - conf, _ := ParseConfig("") + conf, err := ParseConfig("") + require.NoError(t, err) wantData1 := []byte{1, 2, 3} wantData2 := []byte{1, 2} optionsSlice := []auth.ConstructorOption{withData(wantData1)} - err := conf.SetPluginConstructorOptions(constructorTestPluginName, optionsSlice...) + err = conf.SetPluginConstructorOptions(constructorTestPluginName, optionsSlice...) require.NoError(t, err) innerSlice := conf.ConstructorOptions[constructorTestPluginName] require.Len(t, innerSlice, 1) require.Len(t, optionsSlice, 1) - require.Equal(t, reflect.ValueOf(optionsSlice[0]).Pointer(), reflect.ValueOf(innerSlice[0]).Pointer()) + plugin := &testPlugin{} + require.NoError(t, innerSlice[0](plugin)) + require.Equal(t, wantData1, plugin.data) optionsSlice[0] = withData(wantData2) require.Len(t, innerSlice, 1) require.Len(t, optionsSlice, 1) - require.NotEqual(t, reflect.ValueOf(optionsSlice[0]).Pointer(), reflect.ValueOf(innerSlice[0]).Pointer()) + require.NoError(t, innerSlice[0](plugin)) + require.Equal(t, wantData1, plugin.data) + require.NoError(t, optionsSlice[0](plugin)) + require.Equal(t, wantData2, plugin.data) } func TestConnectConfigPassesConstructorOptionsToPlugin(t *testing.T) { From edaf0e8d8835195a0548f6008e39824fe3d03077 Mon Sep 17 00:00:00 2001 From: Matvey Gubanikhin Date: Mon, 9 Nov 2026 14:07:53 +0300 Subject: [PATCH 3/4] Fix linter errors after update to v2.15.0. --- fbconn/compression_close_test.go | 6 ++++-- fbevent/manager_internal_test.go | 2 +- stdlib/sql_go1.27_test.go | 27 --------------------------- 3 files changed, 5 insertions(+), 30 deletions(-) diff --git a/fbconn/compression_close_test.go b/fbconn/compression_close_test.go index 037609b..f843c40 100644 --- a/fbconn/compression_close_test.go +++ b/fbconn/compression_close_test.go @@ -134,7 +134,8 @@ func TestAsyncCloseWaitsForCompressionRead(t *testing.T) { transport.releaseRead() select { - case <-readDone: + case err := <-readDone: + require.ErrorIs(t, err, net.ErrClosed) case <-time.After(time.Second): t.Fatal("compressed read did not finish after the transport was released") } @@ -204,7 +205,8 @@ func testSynchronousCloseUnblocksCompressedReadBeforeCleanup( transport.releaseRead() select { - case <-readDone: + case err := <-readDone: + require.ErrorIs(t, err, net.ErrClosed) case <-time.After(time.Second): t.Fatal("compressed read did not finish after the transport was released") } diff --git a/fbevent/manager_internal_test.go b/fbevent/manager_internal_test.go index baf462e..fa9a621 100644 --- a/fbevent/manager_internal_test.go +++ b/fbevent/manager_internal_test.go @@ -412,7 +412,7 @@ func TestAddEventListenerSerializesConcurrentFirstRegistration(t *testing.T) { }() select { - case <-errSecondCh: + case _ = <-errSecondCh: t.Fatal("second AddEventListener returned before first registration finished") case <-time.After(150 * time.Millisecond): } diff --git a/stdlib/sql_go1.27_test.go b/stdlib/sql_go1.27_test.go index 7392c68..724cd08 100644 --- a/stdlib/sql_go1.27_test.go +++ b/stdlib/sql_go1.27_test.go @@ -5,42 +5,15 @@ package stdlib_test import ( "database/sql" "errors" - "os" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - fbx "github.com/firebirdsql/fbx" "github.com/firebirdsql/fbx/fbtype" "github.com/firebirdsql/fbx/stdlib" ) -func testWithKnownTypeQueryExecModes(t *testing.T, f func(t *testing.T, db *sql.DB)) { - for _, mode := range []fbx.QueryExecMode{ - fbx.QueryExecModeCacheStatement, - fbx.QueryExecModeExec, - } { - t.Run(mode.String(), - func(t *testing.T) { - config, err := fbx.ParseConfig(os.Getenv("FBX_TEST_DATABASE")) - require.NoError(t, err) - - config.DefaultQueryExecMode = mode - db := stdlib.OpenDB(*config) - defer func() { - err := db.Close() - require.NoError(t, err) - }() - - f(t, db) - - ensureDBValid(t, db) - }, - ) - } -} - func TestScanNonEmptyBlob(t *testing.T) { db := openDB(t) From 0779b6ee07153084874a55b54027ab9fe9bb094e Mon Sep 17 00:00:00 2001 From: Matvey Gubanikhin Date: Mon, 9 Nov 2026 14:26:21 +0300 Subject: [PATCH 4/4] Fix chan error checks after updating linter to v2.15.0. --- fbconn/compression_close_test.go | 3 ++- fbconn/events_internal_test.go | 4 +++- fbevent/manager_internal_test.go | 4 +++- fbproto/client_compression_test.go | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/fbconn/compression_close_test.go b/fbconn/compression_close_test.go index f843c40..e5d9a19 100644 --- a/fbconn/compression_close_test.go +++ b/fbconn/compression_close_test.go @@ -197,7 +197,8 @@ func testSynchronousCloseUnblocksCompressedReadBeforeCleanup( } select { - case <-closeDone: + case err := <-closeDone: + require.ErrorIs(t, err, net.ErrClosed) transport.releaseRead() t.Fatal("Close returned while the compressed read was active") case <-time.After(20 * time.Millisecond): diff --git a/fbconn/events_internal_test.go b/fbconn/events_internal_test.go index 8578ef0..b7ab51f 100644 --- a/fbconn/events_internal_test.go +++ b/fbconn/events_internal_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "net" "testing" "time" @@ -166,7 +167,8 @@ func TestSetEventErrorHandlerCalledForFirstFailure(t *testing.T) { } select { - case <-called: + case err := <-called: + require.ErrorIs(t, err, net.ErrClosed) t.Fatal("event error handler called more than once for same error cycle") default: } diff --git a/fbevent/manager_internal_test.go b/fbevent/manager_internal_test.go index fa9a621..a155ed7 100644 --- a/fbevent/manager_internal_test.go +++ b/fbevent/manager_internal_test.go @@ -3,6 +3,7 @@ package fbevent import ( "context" "errors" + "net" "sync" "testing" "time" @@ -412,7 +413,8 @@ func TestAddEventListenerSerializesConcurrentFirstRegistration(t *testing.T) { }() select { - case _ = <-errSecondCh: + case err := <-errSecondCh: + require.ErrorIs(t, err, net.ErrClosed) t.Fatal("second AddEventListener returned before first registration finished") case <-time.After(150 * time.Millisecond): } diff --git a/fbproto/client_compression_test.go b/fbproto/client_compression_test.go index 214fb5d..16eb3a3 100644 --- a/fbproto/client_compression_test.go +++ b/fbproto/client_compression_test.go @@ -467,7 +467,8 @@ func TestCloseCompressionWaitsForActiveCompressedWrite(t *testing.T) { }() <-closeStarted select { - case <-closeDone: + case err := <-closeDone: + require.ErrorIs(t, err, net.ErrClosed) transport.release() t.Fatal("CloseCompression completed during an active compressed write") case <-time.After(20 * time.Millisecond):