conn: skip the second string copy in columnText

Background

(*conn).columnText is the rows.Scan TEXT-column hot path. It currently allocates twice per TEXT column per row: once for the make([]byte, len) buffer that receives the SQLite-owned UTF-8 bytes, and once again inside the string(b) conversion because the compiler must assume the caller could mutate b (runtime.slicebytetostring is the standard path for []byte -> string conversion and always copies).

Here b is local to columnText and is never written to after the initial copy from the C buffer, so the second copy is redundant.

What this MR does

Replaces string(b) with unsafe.String(unsafe.SliceData(b), len). The returned string is built directly on top of b:

  • The string is immutable from Go's perspective.
  • The GC keeps b alive for as long as the string is reachable.
  • b becomes unreachable as []byte the moment the function returns, so no aliasing is possible.

The same pattern is already used in sqlite.go after !120 (merged) for the volatile-args TEXT path; nothing new on the unsafe surface.

(*conn).columnBlob already returns its make([]byte, len) buffer directly and pays only one alloc + memcpy per row, so it is untouched.

Benchmark

darwin/arm64 (Apple M3), 1000-row SELECT of a single TEXT column, -benchtime=2s, before -> after:

Payload allocs/op B/op ns/op
16 B 4009 -> 4009 52348 -> 52348 157342 -> 155746 (~0%)
256 B 5009 -> 4009 548351 -> 292350 226863 -> 204730 (-10%)
4096 B 5009 -> 4009 8228510 -> 4132413 1605640 -> 1135113 (-29%)

-1000 allocs/op = -1 alloc per row, and -N bytes/op = -N bytes per row where N is the column length. The 16-byte case is unchanged because the Go runtime already short-circuits string(b) for slices below its inline threshold; there is no regression at any size.

Tests

TestColumnTextScan exercises the path under -race over the three branches of columnText: empty (short-circuit), short (Go fast path), and long (allocating) TEXT, including a multi-byte / emoji payload to confirm UTF-8 is preserved bit-for-bit. The all-rows variant within the same test reads multiple rows in sequence to catch any stale-pointer regression where the last row's text could bleed into earlier rows.

Full go test -count=1 ./... stays green (467s on darwin/arm64).

Backward compatibility

Pure perf fix; no API change, no behavioral change visible to callers. The returned string has the same value, length, and immutability semantics as before.

Merge request reports

Loading