From 40ba4ee9de2666bfe1579bcbbc5ebbd29c7cc2c0 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Fri, 3 Jul 2026 14:49:49 +0800 Subject: [PATCH] =?UTF-8?q?bnwasm:=20nil=20[]string=E2=86=92NULL,=20fatten?= =?UTF-8?q?=20DbValue=20fixtures,=20doc=20text[]=20NULL-element=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close three WO-WZ-004 review gaps in the guest db driver: - toDbValue: nil []string now marshals to DbValue_Null (matching []byte / json.RawMessage); empty-but-non-nil stays a non-NULL empty text[]. - DbValueFixtures: add edge entries (zero time.Time, negative + very-large numeric strings, empty text[], and text[] elements forcing encodePgTextArray quoting/escaping). Covered automatically by the table-driven round-trip and driver-value tests; new dbvalue_test.go covers the toDbValue nil convention. - Document that TextArray cannot represent a NULL array element (repeated string has no per-element NULL) in the fixtures file and docs/wasm-abi.md, a contract limit the WO-WZ-007 host executor must also honor. Co-Authored-By: Claude Fable 5 --- docs/wasm-abi.md | 6 ++ plugin/wasmguest/bnwasm/dbvalue.go | 5 ++ plugin/wasmguest/bnwasm/dbvalue_fixtures.go | 54 ++++++++++++++- plugin/wasmguest/bnwasm/dbvalue_test.go | 75 +++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 plugin/wasmguest/bnwasm/dbvalue_test.go diff --git a/docs/wasm-abi.md b/docs/wasm-abi.md index 19d39aa..ab15403 100644 --- a/docs/wasm-abi.md +++ b/docs/wasm-abi.md @@ -221,6 +221,12 @@ zero value / best-effort on transport failure. transactions/savepoints are rejected with clear errors — no fleet plugin uses either. The DbValue↔Go scan mapping is pinned in the exported `bnwasm.DbValueFixtures` table, which the WO-WZ-007 host executor mirrors. + **`text[]` NULL-element limit:** `DbValue.text_array` (`abiv1.TextArray`) is a + repeated string with no per-element NULL, so a Postgres `text[]` like + `{a,NULL,b}` cannot round-trip — a NULL element collapses to `""`. The array as + a whole can still be SQL NULL (nil `[]string` → `DbValue_Null`); only a NULL + *inside* the array is unrepresentable. The host executor must honor this same + limit (encode a NULL element as `""` or reject it), not invent a sentinel. - `Interceptors` (`connect.Option`) → host-side only; RBAC merges from `manifest.rbac_method_roles`. - `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`. diff --git a/plugin/wasmguest/bnwasm/dbvalue.go b/plugin/wasmguest/bnwasm/dbvalue.go index 83126e8..52529a3 100644 --- a/plugin/wasmguest/bnwasm/dbvalue.go +++ b/plugin/wasmguest/bnwasm/dbvalue.go @@ -64,6 +64,11 @@ func toDbValue(a any) (*abiv1.DbValue, error) { } 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 string: return &abiv1.DbValue{Kind: &abiv1.DbValue_StringValue{StringValue: v}}, nil diff --git a/plugin/wasmguest/bnwasm/dbvalue_fixtures.go b/plugin/wasmguest/bnwasm/dbvalue_fixtures.go index f7b6788..1105e94 100644 --- a/plugin/wasmguest/bnwasm/dbvalue_fixtures.go +++ b/plugin/wasmguest/bnwasm/dbvalue_fixtures.go @@ -42,7 +42,19 @@ type DbValueFixture struct { // module can import and assert against the same table rather than duplicating a // drift-prone copy. // -// Every oneof arm of abiv1.DbValue.Kind appears exactly once. +// Every oneof arm of abiv1.DbValue.Kind appears at least once; several arms +// carry extra entries that exercise encoding edge cases (the zero timestamp, +// negative/very-large numerics, an empty text[], and text[] elements that force +// encodePgTextArray's quoting/escaping paths) so the host mirror must reproduce +// them too. +// +// Contract limit — NULL array elements: abiv1.TextArray is a repeated string, +// which has no per-element NULL. A Postgres text[] value like '{a,NULL,b}' +// therefore CANNOT round-trip through this boundary: a NULL element collapses to +// the empty string "". The whole array can still be SQL NULL (a nil []string / +// DbValue_Null), but an individual NULL *inside* the array is unrepresentable. +// The WO-WZ-007 host executor MUST honor this same limit (encode a NULL element +// as "" or reject it) — it must not invent a sentinel. var DbValueFixtures = []DbValueFixture{ { Name: "null", @@ -128,4 +140,44 @@ var DbValueFixtures = []DbValueFixture{ Want: []string{"a", "b"}, DriverValue: `{"a","b"}`, }, + + // --- encoding edge cases (extra entries beyond one-per-variant) --- + { + Name: "timestamp_zero", // the Go zero time.Time (year 1) must survive the timestamppb round-trip + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TimestampValue{TimestampValue: timestamppb.New(time.Time{})}}, + NewDest: func() any { return new(time.Time) }, + Want: time.Time{}, + DriverValue: time.Time{}, + }, + { + Name: "numeric_negative", + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "-98765.4321"}}, + NewDest: func() any { return new(string) }, + Want: "-98765.4321", + DriverValue: "-98765.4321", + }, + { + Name: "numeric_large", // far beyond int64/float64 range: numeric stays a lossless decimal string + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_NumericValue{NumericValue: "123456789012345678901234567890.123456789"}}, + NewDest: func() any { return new(string) }, + Want: "123456789012345678901234567890.123456789", + DriverValue: "123456789012345678901234567890.123456789", + }, + { + Name: "text_array_empty", // empty-but-non-nil text[] → "{}" (distinct from a NULL array) + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{}}}}, + NewDest: func() any { return new([]string) }, + Want: []string(nil), + DriverValue: "{}", + }, + { + // Elements that force encodePgTextArray's quoting/escaping: a comma + // (needs quoting), a double-quote and a backslash (need escaping), and an + // empty-string element (renders as the empty quoted ""). + Name: "text_array_quoting", + Value: &abiv1.DbValue{Kind: &abiv1.DbValue_TextArrayValue{TextArrayValue: &abiv1.TextArray{Values: []string{"a,b", `x"y`, `p\q`, ""}}}}, + NewDest: func() any { return new([]string) }, + Want: []string{"a,b", `x"y`, `p\q`, ""}, + DriverValue: `{"a,b","x\"y","p\\q",""}`, + }, } diff --git a/plugin/wasmguest/bnwasm/dbvalue_test.go b/plugin/wasmguest/bnwasm/dbvalue_test.go new file mode 100644 index 0000000..57939ce --- /dev/null +++ b/plugin/wasmguest/bnwasm/dbvalue_test.go @@ -0,0 +1,75 @@ +package bnwasm + +import ( + "encoding/json" + "reflect" + "testing" + + abiv1 "git.dev.alexdunmow.com/block/core/abi/v1" +) + +// TestToDbValueTextArrayNilVsEmpty pins the guest-side arg encoding for []string: +// a nil slice marshals to SQL NULL (matching []byte/json.RawMessage), while an +// empty-but-non-nil slice stays a non-NULL empty text[]. WO-WZ-007's host must +// mirror this nil→NULL convention. +func TestToDbValueTextArrayNilVsEmpty(t *testing.T) { + t.Run("nil", func(t *testing.T) { + dv, err := toDbValue([]string(nil)) + if err != nil { + t.Fatal(err) + } + if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok { + t.Fatalf("nil []string: want DbValue_Null, got %T", dv.GetKind()) + } + }) + t.Run("empty", func(t *testing.T) { + dv, err := toDbValue([]string{}) + if err != nil { + t.Fatal(err) + } + ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue) + if !ok { + t.Fatalf("empty []string: want DbValue_TextArrayValue, got %T", dv.GetKind()) + } + if got := ta.TextArrayValue.GetValues(); len(got) != 0 { + t.Fatalf("empty []string: want zero-length text[], got %#v", got) + } + }) + t.Run("values", func(t *testing.T) { + dv, err := toDbValue([]string{"a", "b"}) + if err != nil { + t.Fatal(err) + } + ta, ok := dv.GetKind().(*abiv1.DbValue_TextArrayValue) + if !ok { + t.Fatalf("want DbValue_TextArrayValue, got %T", dv.GetKind()) + } + if got := ta.TextArrayValue.GetValues(); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("want [a b], got %#v", got) + } + }) +} + +// TestToDbValueNilConvention locks the nil→NULL convention across the reference +// types so []string stays consistent with []byte and json.RawMessage. +func TestToDbValueNilConvention(t *testing.T) { + cases := []struct { + name string + in any + }{ + {"bytes", []byte(nil)}, + {"json", json.RawMessage(nil)}, + {"text_array", []string(nil)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dv, err := toDbValue(c.in) + if err != nil { + t.Fatal(err) + } + if _, ok := dv.GetKind().(*abiv1.DbValue_Null); !ok { + t.Fatalf("nil %s: want DbValue_Null, got %T", c.name, dv.GetKind()) + } + }) + } +}