feat: release nullable mappings and split mapping configuration

This commit is contained in:
2026-09-25 13:56:44 +08:00
parent 5fc82509a1
commit 4563a6354f
27 changed files with 1148 additions and 46 deletions

View File

@@ -0,0 +1,170 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
"strings"
"sync"
"testing"
"example.com/nullable/internal/convert"
"example.com/nullable/internal/domain"
"example.com/nullable/internal/handler"
"example.com/nullable/internal/mapping"
"example.com/nullable/internal/svc"
"example.com/nullable/internal/types"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestNullableMappings(t *testing.T) {
for _, src := range []types.Parent{{}, {Children: []*types.Child{}}, {Child: &types.Child{}, Children: []*types.Child{nil, {Name: "two"}}}} {
out, err := mapping.ToParent(src)
if err != nil || (src.Child == nil) != (out.Child == nil) || (src.Children == nil) != (out.Children == nil) || len(src.Children) != len(out.Children) {
t.Fatal(src, out, err)
}
if src.Child != nil && out.Child.Name != src.Child.Name {
t.Fatal("zero child changed")
}
if len(out.Children) > 0 && (out.Children[0] != nil || out.Children[1].Name != "two") {
t.Fatal(out)
}
}
src := types.Parent{Child: &types.Child{Name: "one"}, Children: []*types.Child{{Name: "two"}}}
out, err := mapping.ToParentPtr(src)
if err != nil || out == nil || out.Child.Name != "one" {
t.Fatal(out, err)
}
out.Child.Name = "changed"
out.Children[0].Name = "changed"
if src.Child.Name != "one" || src.Children[0].Name != "two" {
t.Fatal("nested mapped values alias input")
}
plain := domain.Parent{Child: &domain.Child{Name: "shared"}}
copy, err := mapping.ShallowCopy(plain)
if err != nil || copy.Child != plain.Child {
t.Fatal("ordinary assignment semantics changed", err)
}
zero, err := mapping.RequiredZero(types.Parent{})
if err != nil || zero.Child.Name != "" {
t.Fatal(zero, err)
}
for _, fn := range []func(types.Parent) (domain.Required, error){mapping.RequiredZero, mapping.RequiredError} {
out, err := fn(types.Parent{Child: &types.Child{Name: "required"}})
if err != nil || out.Child.Name != "required" {
t.Fatal(out, err)
}
}
_, err = mapping.RequiredError(types.Parent{})
if err == nil || !strings.Contains(err.Error(), "RequiredError.Child") {
t.Fatal(err)
}
_, err = mapping.RequirePointer(types.Parent{})
if err == nil || !strings.Contains(err.Error(), "RequirePointer.Child") {
t.Fatal(err)
}
lift, err := mapping.Lift(domain.ValueHolder{Child: domain.Child{Name: "lift"}})
if err != nil || lift.Child == nil || lift.Child.Name != "lift" {
t.Fatal(lift, err)
}
batch, err := mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "array"}}})
if err != nil || batch.Fixed[0] != nil || batch.Fixed[1].Name != "array" {
t.Fatal(batch, err)
}
_, err = mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "bad"}}})
if !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "Batch.Fixed[1]") {
t.Fatal(err)
}
for _, input := range []types.Parent{{Child: &types.Child{Name: "bad"}}, {Children: []*types.Child{nil, {Name: "bad"}}}} {
failed, err := mapping.ToParentPtr(input)
if failed != nil || !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "ToChild.Name") {
t.Fatal(failed, err)
}
if len(input.Children) > 0 && !strings.Contains(err.Error(), "ToParent.Children[1]") {
t.Fatal(err)
}
value, err := mapping.ToParent(input)
if value.Child != nil || value.Children != nil || !errors.Is(err, convert.ErrValue) {
t.Fatal("partial result escaped", err)
}
}
}
func TestExplicitNilAndConcurrentCalls(t *testing.T) {
zero, err := mapping.ScalarZero(domain.ScalarInput{})
if err != nil || zero != (domain.ScalarOutput{}) {
t.Fatal(zero, err)
}
_, err = mapping.ScalarError(domain.ScalarInput{})
if err == nil {
t.Fatal("nil accepted")
}
text, count, enabled := "", int64(0), false
out, err := mapping.ScalarError(domain.ScalarInput{Text: &text, Count: &count, Enabled: &enabled})
if err != nil || out != (domain.ScalarOutput{}) {
t.Fatal("non-nil zero rejected", out, err)
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, err := mapping.ToParent(types.Parent{Child: &types.Child{Name: "parallel"}})
if err != nil || out.Child == nil || out.Child.Name != "parallel" {
t.Error(out, err)
}
}()
}
wg.Wait()
}
func TestHTTPNullableMapping(t *testing.T) {
cfg := logx.Default()
cfg.Writer = io.Discard
logger, err := logx.New(cfg)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := svc.New(context.Background(), logger)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app.Close()
for _, body := range []string{`{}`, `{"child":null}`, `{"child":{"name":""}}`, `{"child":{"name":"one"},"children":[null,{"name":"two"}]}`, `{"children":[]}`} {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
app.Engine().ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var input, output types.Parent
if err := json.Unmarshal([]byte(body), &input); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil {
t.Fatal(err)
}
if (input.Child == nil) != (output.Child == nil) || (input.Children == nil) != (output.Children == nil) || len(input.Children) != len(output.Children) {
t.Fatal(body, w.Body.String())
}
if input.Child != nil && input.Child.Name != output.Child.Name {
t.Fatal(w.Body.String())
}
}
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"child":{"name":"bad"}}`))
req.Header.Set("Content-Type", "application/json")
app.Engine().ServeHTTP(w, req)
if w.Code != 500 || strings.Contains(w.Body.String(), convert.ErrValue.Error()) || strings.Contains(w.Body.String(), "ToChild") {
t.Fatal("private cause leaked", w.Code, w.Body.String())
}
}

View File

@@ -0,0 +1,15 @@
package convert
import (
"errors"
"fmt"
)
var ErrValue = errors.New("private conversion cause")
func Name(value string) (string, error) {
if value == "bad" {
return "", fmt.Errorf("conversion refused: %w", ErrValue)
}
return value, nil
}

View File

@@ -0,0 +1,23 @@
package domain
type Child struct{ Name string }
type ChildDTO struct{ Name string }
type Parent struct {
Child *Child
Children []*Child
}
type Required struct{ Child Child }
type ValueHolder struct{ Child Child }
type PointerHolder struct{ Child *ChildDTO }
type InputBatch struct{ Fixed [2]*Child }
type OutputBatch struct{ Fixed [2]*ChildDTO }
type ScalarInput struct {
Text *string
Count *int64
Enabled *bool
}
type ScalarOutput struct {
Text string
Count int64
Enabled bool
}

View File

@@ -0,0 +1,15 @@
type Child {
Name string `json:"name"`
}
type Parent {
Child *Child `json:"child,optional"`
Children []*Child `json:"children,optional"`
}
@server (
group: probe
response: raw
)
service probe {
@handler roundTrip
post / (Parent) returns (Parent)
}