package bnwasm import ( "database/sql" "database/sql/driver" "encoding/json" "fmt" "reflect" "strings" "time" abiv1 "git.dev.alexdunmow.com/block/pluginsdk/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: // nil → NULL (matching []byte/json.RawMessage); an empty-but-non-nil // slice stays a non-NULL empty text[]. if v == nil { return nullValue(), nil } return &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: v}}}, nil case []uuid.UUID: // uuid[] gets its own variant so the host binds a native uuid[] // parameter (queries keep `ANY($1::uuid[])` — no text[]-cast // workaround). nil → NULL / empty stays a non-NULL empty uuid[], // mirroring the []string convention. if v == nil { return nullValue(), nil } strs := make([]string, len(v)) for i, id := range v { strs[i] = id.String() } return &abiv1.DbValue{Kind: &abiv1.DbValue_UuidArrayValue{UuidArrayValue: &abiv1.UuidArray{Values: strs}}}, 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) // uuid_array → ([]string, false) // canonical forms; assign parses into []uuid.UUID 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 case *abiv1.DbValue_UuidArrayValue: // Raw canonical strings, mirroring the single-uuid arm returning a // string: assign() parses them into a []uuid.UUID destination (with // error propagation), the same way uuid.UUID.Scan parses the scalar. return append([]string(nil), k.UuidArrayValue.GetValues()...), false default: return nil, true } } // uuidSliceType is the reflect.Type of []uuid.UUID, the canonical scan // destination sqlc emits for a uuid[] column (and COALESCE(...::uuid[]) arg). var uuidSliceType = reflect.TypeFor[[]uuid.UUID]() // 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) // uuid[] natural value ([]string of canonical UUIDs) → []uuid.UUID: parse // each element (mirrors uuid.UUID.Scan for the scalar). reflect can neither // assign nor convert []string to []uuid.UUID, so this must be explicit. if dst.Type() == uuidSliceType { if strs, ok := nv.([]string); ok { out := make([]uuid.UUID, len(strs)) for i, s := range strs { id, err := uuid.Parse(s) if err != nil { return fmt.Errorf("bnwasm: cannot scan %q into uuid at index %d: %w", s, i, err) } out[i] = id } dst.Set(reflect.ValueOf(out)) return nil } } // 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() }