feat: replace mapping generation with checked copy utilities

This commit is contained in:
2026-09-25 17:27:28 +08:00
parent ad9257d202
commit fd83235ce7
45 changed files with 2366 additions and 1872 deletions

View File

@@ -18,7 +18,6 @@ import (
"example.com/composition/internal/convert"
"example.com/composition/internal/domain"
"example.com/composition/internal/handler"
"example.com/composition/internal/mapping"
"example.com/composition/internal/middleware"
"example.com/composition/internal/svc"
"example.com/composition/internal/types"
@@ -86,16 +85,16 @@ func TestHTTPComposition(t *testing.T) {
}
}
}
func TestFullFormAndMappingSemantics(t *testing.T) {
func TestFullFormAndCopySemantics(t *testing.T) {
name := "employee"
money := amount.Money("2.00")
yes := true
req := types.EnterpriseRequest{Name: &name, Salary: &money, Enabled: &yes, Labels: []string{"one"}}
first, e := mapping.FromEnterprise(req)
first, e := convert.Employee(req)
if e != nil {
t.Fatal(e)
}
second, e := mapping.FromEnterprise(types.EnterpriseRequest{})
second, e := convert.Employee(types.EnterpriseRequest{})
if e != nil {
t.Fatal(e)
}
@@ -108,26 +107,26 @@ func TestFullFormAndMappingSemantics(t *testing.T) {
t.Fatal("unexpected implicit deep copy")
}
req.Employer = &money
first, e = mapping.FromEnterprise(req)
first, e = convert.Employee(req)
if e != nil || first.Employer != req.Employer {
t.Fatal("nullable pointer changed", e)
}
bad := "reject"
failed, e := mapping.FromEnterprise(types.EnterpriseRequest{Name: &bad})
failed, e := convert.Employee(types.EnterpriseRequest{Name: &bad})
if !errors.Is(e, convert.ErrName) || failed.EmployeeName != "" || !strings.Contains(e.Error(), "EmployeeName") {
t.Fatal("converter cause/zero result lost", e)
}
empty, e := mapping.ToBatch(domain.Batch{})
empty, e := convert.ToBatch(domain.Batch{})
if e != nil || empty.Rows != nil {
t.Fatal("nil slice changed", e)
}
src := domain.Batch{Rows: []domain.Row{}, Fixed: [2]domain.Row{{Value: "a"}, {Value: "b"}}, Optional: &name}
out, e := mapping.ToBatch(src)
out, e := convert.ToBatch(src)
if e != nil || out.Rows == nil || len(out.Rows) != 0 || out.Fixed[1].Value != "b" || out.Optional != src.Optional {
t.Fatal(out, e)
}
src.Rows = []domain.Row{{Value: "one"}}
out, e = mapping.ToBatch(src)
out, e = convert.ToBatch(src)
if e != nil || out.Rows[0].Value != "one" {
t.Fatal(out, e)
}
@@ -140,7 +139,7 @@ func TestFullFormAndMappingSemantics(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
result, err := mapping.FromEnterprise(types.EnterpriseRequest{Name: &name, Salary: &money})
result, err := convert.Employee(types.EnterpriseRequest{Name: &name, Salary: &money})
if err != nil || result.EmployeeName != name || result.Salary != money {
t.Error(result, err)
}

View File

@@ -2,7 +2,6 @@ package convert
import (
"errors"
"example.com/composition/internal/amount"
"strconv"
)
@@ -19,11 +18,4 @@ func Name(value *string) (string, error) {
}
return *value, nil
}
func Money(value *amount.Money) amount.Money {
if value == nil {
return ""
}
return *value
}
func Bool(value *bool) bool { return value != nil && *value }
func ID(value int64) string { return strconv.FormatInt(value, 10) }

View File

@@ -0,0 +1,29 @@
package convert
import (
"example.com/composition/internal/domain"
"example.com/composition/internal/types"
"git.yuntuo.hk/k2safe/go-omni/copyx"
)
// The three endpoints reuse one ordinary Go helper and their existing scalar.
// There is no field list or package/type name JSON to keep in sync.
func Employee[S any](source S) (domain.Employee, error) {
var target domain.Employee
err := copyx.Copy(&target, source,
copyx.WithFieldNames[S, domain.Employee](map[string]string{"Name": "EmployeeName"}),
copyx.WithNilPolicy(copyx.NilZero),
copyx.WithConverter(Name),
)
return target, err
}
func Response(source domain.Result) (types.EmployeeResponse, error) {
var target types.EmployeeResponse
err := copyx.Copy(&target, source, copyx.WithConverter(func(v int64) (string, error) { return ID(v), nil }))
return target, err
}
func ToBatch(source domain.Batch) (domain.BatchDTO, error) {
var target domain.BatchDTO
err := copyx.Copy(&target, source)
return target, err
}

View File

@@ -14,7 +14,7 @@ type Result struct {
ID int64
}
// Distinct container item types exercise explicitly selected element mappings.
// Distinct container item types exercise explicit Go collection copying.
type Row struct{ Value string }
type RowDTO struct{ Value string }
type Batch struct {

View File

@@ -1,170 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,15 +0,0 @@
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)
}