Alex Dunmow 7881aeee05 feat(wasmguest): guest DB driver bnwasm (WO-WZ-004)
Guest-side database access over the db.* host calls (abiv1 db.proto),
two surfaces sharing one injected transport:

- database/sql driver registered as "bnwasm" (driver.go) — QueryContext/
  ExecContext/BeginTx over db.query/db.exec/db.tx_*; named args rejected.
- plugin.Pool (pool.go) handing out a pgx.Tx-shaped value (tx.go), so the
  pgx-flavored sqlc DBTX every current plugin generates against
  (sql_package: pgx/v5) is satisfied with no source edits. This is the
  primary path: their DBTX needs pgconn.CommandTag/pgx.Rows/pgx.Row, which
  database/sql cannot produce.

DbValue↔Go mapping (dbvalue.go) covers all 11 oneof arms both directions;
DbError surfaces as *pgconn.PgError (SQLSTATE preserved for errors.As);
nested tx/savepoints rejected with a clear error (no fleet plugin uses
them). The scan contract is pinned in the exported DbValueFixtures table
(dbvalue_fixtures.go) that the WO-WZ-007 host executor mirrors.

Tests: driver_test.go (fake host — every DbValue variant round-trips with
correct scan types, exec rows-affected, ordered host-call assertions for
tx commit/rollback sequences, post-rollback autocommit carries no handle)
and sqlcgen_test.go (vendored sqlc-style Queries + WithTx run against the
fake host). Native + wasip1 builds green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:41:45 +08:00

279 lines
8.9 KiB
Go

package bnwasm
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"strings"
"time"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"google.golang.org/protobuf/types/known/timestamppb"
)
// toDbValues marshals a positional argument list to wire DbValues. Named args
// (pgx.NamedArgs, or any pgx.QueryRewriter passed as the sole argument) are
// rejected: sqlc emits positional parameters, and rewriting a query host-side
// is out of scope for v1.
func toDbValues(args []any) ([]*abiv1.DbValue, error) {
if len(args) == 1 {
if _, ok := args[0].(pgx.QueryRewriter); ok {
return nil, fmt.Errorf("bnwasm: named arguments (pgx.QueryRewriter/NamedArgs) are not supported; use positional parameters")
}
}
out := make([]*abiv1.DbValue, len(args))
for i, a := range args {
v, err := toDbValue(a)
if err != nil {
return nil, fmt.Errorf("bnwasm: arg %d: %w", i, err)
}
out[i] = v
}
return out, nil
}
// toDbValue maps one Go query argument to a DbValue. Concrete BlockNinja/pgx
// types (uuid, time, text[], json) are special-cased so they round-trip to
// their dedicated DbValue variant; anything implementing driver.Valuer (pgtype
// scalars, sql.Null*) is normalized through Value(); the rest goes by kind.
func toDbValue(a any) (*abiv1.DbValue, error) {
switch v := a.(type) {
case nil:
return nullValue(), nil
case uuid.UUID:
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.String()}}, nil
case uuid.NullUUID:
if !v.Valid {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidValue{UuidValue: v.UUID.String()}}, nil
case time.Time:
return &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(v)}}, nil
case json.RawMessage:
if v == nil {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_JsonbValue{JsonbValue: v}}, nil
case []byte:
if v == nil {
return nullValue(), nil
}
return &abiv1.DbValue{Kind: &abiv1.DbValue_BytesValue{BytesValue: v}}, nil
case []string:
return &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: v}}}, nil
case string:
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: v}}, nil
case bool:
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: v}}, nil
}
// driver.Valuer covers pgtype scalars (Timestamptz, Numeric, Int4, ...) and
// sql.Null*; recurse on the normalized driver.Value.
if valuer, ok := a.(driver.Valuer); ok {
dv, err := valuer.Value()
if err != nil {
return nil, err
}
if dv == nil {
return nullValue(), nil
}
return toDbValue(dv)
}
// Fall back to reflection for pointers and the basic numeric kinds.
rv := reflect.ValueOf(a)
switch rv.Kind() {
case reflect.Pointer:
if rv.IsNil() {
return nullValue(), nil
}
return toDbValue(rv.Elem().Interface())
case reflect.Bool:
return &abiv1.DbValue{Kind: &abiv1.DbValue_BoolValue{BoolValue: rv.Bool()}}, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: rv.Int()}}, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Int64Value{Int64Value: int64(rv.Uint())}}, nil
case reflect.Float32, reflect.Float64:
return &abiv1.DbValue{Kind: &abiv1.DbValue_Float64Value{Float64Value: rv.Float()}}, nil
case reflect.String:
return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: rv.String()}}, nil
}
return nil, fmt.Errorf("bnwasm: unsupported argument type %T", a)
}
func nullValue() *abiv1.DbValue {
return &abiv1.DbValue{Kind: &abiv1.DbValue_Null{Null: true}}
}
// naturalValue returns the canonical Go scan value for a DbValue and whether it
// is SQL NULL. This is the semantic contract WO-WZ-007 (host executor) mirrors:
//
// null → (nil, true)
// bool → (bool, false)
// int64 → (int64, false)
// float64 → (float64, false)
// string → (string, false)
// bytes → ([]byte, false)
// timestamp → (time.Time,false)
// uuid → (string, false) // canonical form; uuid.UUID.Scan accepts it
// jsonb → ([]byte, false)
// numeric → (string, false) // lossless decimal string
// text_array → ([]string, false)
func naturalValue(dv *abiv1.DbValue) (any, bool) {
switch k := dv.GetKind().(type) {
case *abiv1.DbValue_Null:
return nil, true
case *abiv1.DbValue_BoolValue:
return k.BoolValue, false
case *abiv1.DbValue_Int64Value:
return k.Int64Value, false
case *abiv1.DbValue_Float64Value:
return k.Float64Value, false
case *abiv1.DbValue_StringValue:
return k.StringValue, false
case *abiv1.DbValue_BytesValue:
return k.BytesValue, false
case *abiv1.DbValue_TimestampValue:
return k.TimestampValue.AsTime(), false
case *abiv1.DbValue_UuidValue:
return k.UuidValue, false
case *abiv1.DbValue_JsonbValue:
return []byte(k.JsonbValue), false
case *abiv1.DbValue_NumericValue:
return k.NumericValue, false
case *abiv1.DbValue_TextArrayValue:
return append([]string(nil), k.TextArrayValue.GetValues()...), false
default:
return nil, true
}
}
// scanValue assigns a DbValue into a destination pointer with pgx-flavored Scan
// semantics: a nil dest skips the column; a sql.Scanner destination is fed the
// natural value (including nil for NULL); pointer-to-pointer destinations model
// nullability (set nil on NULL); everything else is assigned by reflection with
// integer/float/string/[]byte/[]string coercion.
func scanValue(dv *abiv1.DbValue, dest any) error {
if dest == nil {
return nil // pgx: nil dest skips the value entirely
}
nv, isNull := naturalValue(dv)
if sc, ok := dest.(sql.Scanner); ok {
return sc.Scan(nv)
}
rv := reflect.ValueOf(dest)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return fmt.Errorf("bnwasm: scan destination must be a non-nil pointer, got %T", dest)
}
elem := rv.Elem()
// Pointer-to-pointer (e.g. **string): nullable column mapped to *string.
if elem.Kind() == reflect.Pointer {
if isNull {
elem.Set(reflect.Zero(elem.Type()))
return nil
}
np := reflect.New(elem.Type().Elem())
if err := assign(np.Elem(), nv); err != nil {
return err
}
elem.Set(np)
return nil
}
if isNull {
elem.Set(reflect.Zero(elem.Type()))
return nil
}
return assign(elem, nv)
}
// assign coerces a natural value into a concrete (non-pointer) destination.
func assign(dst reflect.Value, nv any) error {
src := reflect.ValueOf(nv)
// Direct assignability (string→string, time.Time→time.Time, []string→[]string).
if src.Type().AssignableTo(dst.Type()) {
dst.Set(src)
return nil
}
// Convertibility (json.RawMessage←[]byte, int64→int32, float64→float32,
// named string types, []byte→string, ...).
if src.Type().ConvertibleTo(dst.Type()) {
// Guard against lossy string<->numeric "conversions" reflect allows for
// rune/byte-ish types by only converting between compatible kinds.
if convertibleKinds(src.Kind(), dst.Kind()) {
dst.Set(src.Convert(dst.Type()))
return nil
}
}
return fmt.Errorf("bnwasm: cannot scan %s into %s", src.Type(), dst.Type())
}
func convertibleKinds(src, dst reflect.Kind) bool {
num := func(k reflect.Kind) bool {
switch k {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
return true
}
return false
}
switch {
case num(src) && num(dst):
return true
case src == reflect.String && dst == reflect.String:
return true
case src == reflect.Slice && dst == reflect.Slice: // []byte→json.RawMessage, []string→named
return true
case src == reflect.Slice && dst == reflect.String: // []byte→string
return true
case src == reflect.String && dst == reflect.Slice: // string→[]byte
return true
}
return false
}
// driverValue maps a DbValue to a database/sql driver.Value (the closed set:
// nil, int64, float64, bool, []byte, string, time.Time). uuid/numeric surface
// as strings and text[] as a Postgres array literal string, since driver.Value
// has no richer representation; callers scan those through the column's
// sql.Scanner as usual.
func driverValue(dv *abiv1.DbValue) driver.Value {
nv, isNull := naturalValue(dv)
if isNull {
return nil
}
switch v := nv.(type) {
case []string:
return encodePgTextArray(v)
default:
return v
}
}
// encodePgTextArray renders a []string as a Postgres text[] literal, e.g.
// {"a","b,c"}, quoting/escaping every element for lossless reconstruction.
func encodePgTextArray(vals []string) string {
var b strings.Builder
b.WriteByte('{')
for i, s := range vals {
if i > 0 {
b.WriteByte(',')
}
b.WriteByte('"')
b.WriteString(strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s))
b.WriteByte('"')
}
b.WriteByte('}')
return b.String()
}