feat: 添加对 @Bind 注释中 model 字段的支持,优化路径参数绑定逻辑并更新文档
This commit is contained in:
@@ -85,31 +85,41 @@ func buildRenderData(opts RenderBuildOpts) (RenderData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildParamToken(item ParamDefinition) string {
|
func buildParamToken(item ParamDefinition) string {
|
||||||
key := item.Name
|
key := item.Name
|
||||||
if item.Key != "" {
|
if item.Key != "" {
|
||||||
key = item.Key
|
key = item.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
switch item.Position {
|
switch item.Position {
|
||||||
case PositionQuery:
|
case PositionQuery:
|
||||||
return fmt.Sprintf(`Query%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key)
|
return fmt.Sprintf(`Query%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key)
|
||||||
case PositionHeader:
|
case PositionHeader:
|
||||||
return fmt.Sprintf(`Header[%s]("%s")`, item.Type, key)
|
return fmt.Sprintf(`Header[%s]("%s")`, item.Type, key)
|
||||||
case PositionFile:
|
case PositionFile:
|
||||||
return fmt.Sprintf(`File[multipart.FileHeader]("%s")`, key)
|
return fmt.Sprintf(`File[multipart.FileHeader]("%s")`, key)
|
||||||
case PositionCookie:
|
case PositionCookie:
|
||||||
if item.Type == "string" {
|
if item.Type == "string" {
|
||||||
return fmt.Sprintf(`CookieParam("%s")`, key)
|
return fmt.Sprintf(`CookieParam("%s")`, key)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(`Cookie[%s]("%s")`, item.Type, key)
|
return fmt.Sprintf(`Cookie[%s]("%s")`, item.Type, key)
|
||||||
case PositionBody:
|
case PositionBody:
|
||||||
return fmt.Sprintf(`Body[%s]("%s")`, item.Type, key)
|
return fmt.Sprintf(`Body[%s]("%s")`, item.Type, key)
|
||||||
case PositionPath:
|
case PositionPath:
|
||||||
return fmt.Sprintf(`Path%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key)
|
// If a model field is specified, generate a model-lookup binder from path value.
|
||||||
case PositionLocal:
|
if item.ModelField != "" || item.Model != "" {
|
||||||
return fmt.Sprintf(`Local[%s]("%s")`, item.Type, key)
|
field := item.ModelField
|
||||||
}
|
if field == "" {
|
||||||
return ""
|
field = "id"
|
||||||
|
}
|
||||||
|
// PathModel is expected to resolve the path param to the specified model by field.
|
||||||
|
// Example: PathModel[models.User]("id", "user_id")
|
||||||
|
return fmt.Sprintf(`PathModel[%s]("%s", "%s")`, item.Type, field, key)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`Path%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key)
|
||||||
|
case PositionLocal:
|
||||||
|
return fmt.Sprintf(`Local[%s]("%s")`, item.Type, key)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func scalarSuffix(t string) string {
|
func scalarSuffix(t string) string {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ type ParamDefinition struct {
|
|||||||
Type string
|
Type string
|
||||||
Key string
|
Key string
|
||||||
Model string
|
Model string
|
||||||
|
// ModelField is the field/column name used to lookup the model when Model is set.
|
||||||
|
// Example: `@Bind user path key(id) model(database/models.User:id)` -> Model=database/models.User, ModelField=id
|
||||||
|
ModelField string
|
||||||
Position Position
|
Position Position
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +141,9 @@ func ParseFile(file string) []RouteDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(line, "@Bind") {
|
if strings.HasPrefix(line, "@Bind") {
|
||||||
//@Bind name [uri|query|path|body|header|cookie] [key()] [table()] [model()]
|
//@Bind name [uri|query|path|body|header|cookie] [key()] [table()] [model(<pkg>.<Type>[:<field>])]
|
||||||
bindParams = append(bindParams, parseRouteBind(line))
|
bindParams = append(bindParams, parseRouteBind(line))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "" || method == "" {
|
if path == "" || method == "" {
|
||||||
@@ -236,24 +239,44 @@ func parseRouteComment(line string) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseRouteBind(bind string) ParamDefinition {
|
func parseRouteBind(bind string) ParamDefinition {
|
||||||
var param ParamDefinition
|
var param ParamDefinition
|
||||||
parts := strings.FieldsFunc(bind, func(r rune) bool {
|
parts := strings.FieldsFunc(bind, func(r rune) bool {
|
||||||
return r == ' ' || r == '(' || r == ')' || r == '\t'
|
return r == ' ' || r == '(' || r == ')' || r == '\t'
|
||||||
})
|
})
|
||||||
parts = lo.Filter(parts, func(item string, idx int) bool {
|
parts = lo.Filter(parts, func(item string, idx int) bool {
|
||||||
return item != ""
|
return item != ""
|
||||||
})
|
})
|
||||||
|
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
switch part {
|
switch part {
|
||||||
case "@Bind":
|
case "@Bind":
|
||||||
param.Name = parts[i+1]
|
param.Name = parts[i+1]
|
||||||
param.Position = positionFromString(parts[i+2])
|
param.Position = positionFromString(parts[i+2])
|
||||||
case "key":
|
case "key":
|
||||||
param.Key = parts[i+1]
|
param.Key = parts[i+1]
|
||||||
case "model":
|
case "model":
|
||||||
param.Model = parts[i+1]
|
// Supported formats:
|
||||||
|
// - model(field) -> only specify model field/column; model type inferred from parameter
|
||||||
|
// - model(pkg/path.Type) -> type hint (optional); default field will be used later
|
||||||
|
// - model(pkg/path.Type:id) or model(pkg/path.Type#id) -> type + field
|
||||||
|
mv := parts[i+1]
|
||||||
|
// if mv contains no dot, treat as field name directly
|
||||||
|
if !strings.Contains(mv, ".") && !strings.Contains(mv, "/") {
|
||||||
|
param.ModelField = mv
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// otherwise try type[:field]
|
||||||
|
fieldSep := ":"
|
||||||
|
if strings.Contains(mv, "#") {
|
||||||
|
fieldSep = "#"
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(mv, fieldSep); idx > 0 && idx < len(mv)-1 {
|
||||||
|
param.Model = mv[:idx]
|
||||||
|
param.ModelField = mv[idx+1:]
|
||||||
|
} else {
|
||||||
|
param.Model = mv
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return param
|
return param
|
||||||
}
|
}
|
||||||
|
|||||||
90
pkg/ast/route/route_model_bind_test.go
Normal file
90
pkg/ast/route/route_model_bind_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.ipao.vip/atomctl/v2/pkg/utils/gomod"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test that @Bind with model(field) on a path parameter generates PathModel[T](field, key)
|
||||||
|
func Test_PathModelBind_FromRouteComments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := `package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct{}
|
||||||
|
|
||||||
|
type Demo struct{}
|
||||||
|
|
||||||
|
// @Router /users/:id [get]
|
||||||
|
// @Bind user path key(id) model(id)
|
||||||
|
func (d *Demo) Show(ctx context.Context, user *User) (*User, error) {
|
||||||
|
return nil, nil
|
||||||
|
}`
|
||||||
|
|
||||||
|
// minimal go.mod so gomod.GetPackageModuleName works without panic
|
||||||
|
gomodPath := filepath.Join(dir, "go.mod")
|
||||||
|
goModContent := "module example.com/test\n\ngo 1.23\n"
|
||||||
|
if err := os.WriteFile(gomodPath, []byte(goModContent), 0o644); err != nil {
|
||||||
|
t.Fatalf("write go.mod: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gomod.Parse(gomodPath); err != nil {
|
||||||
|
t.Fatalf("gomod.Parse error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := filepath.Join(dir, "demo.go")
|
||||||
|
if err := os.WriteFile(file, []byte(src), 0o644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := ParseFile(file)
|
||||||
|
if len(defs) != 1 {
|
||||||
|
t.Fatalf("expected 1 route definition, got %d", len(defs))
|
||||||
|
}
|
||||||
|
if len(defs[0].Actions) != 1 {
|
||||||
|
t.Fatalf("expected 1 action, got %d", len(defs[0].Actions))
|
||||||
|
}
|
||||||
|
act := defs[0].Actions[0]
|
||||||
|
if len(act.Params) != 1 {
|
||||||
|
t.Fatalf("expected 1 param, got %d", len(act.Params))
|
||||||
|
}
|
||||||
|
p := act.Params[0]
|
||||||
|
if p.Position != PositionPath {
|
||||||
|
t.Fatalf("expected path position, got %s", p.Position)
|
||||||
|
}
|
||||||
|
if p.Key != "id" {
|
||||||
|
t.Fatalf("expected key=id, got %s", p.Key)
|
||||||
|
}
|
||||||
|
if p.ModelField != "id" {
|
||||||
|
t.Fatalf("expected ModelField=id, got %s", p.ModelField)
|
||||||
|
}
|
||||||
|
if p.Type != "User" { // pointer should be trimmed for non-local
|
||||||
|
t.Fatalf("expected Type=User, got %s", p.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build render data and check binder token
|
||||||
|
rd, err := buildRenderData(RenderBuildOpts{
|
||||||
|
PackageName: "v1",
|
||||||
|
ProjectPackage: "example.com/test",
|
||||||
|
Routes: defs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRenderData error: %v", err)
|
||||||
|
}
|
||||||
|
// Render to text and assert PathModel usage
|
||||||
|
out, err := renderTemplate(rd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderTemplate error: %v", err)
|
||||||
|
}
|
||||||
|
got := string(out)
|
||||||
|
if !strings.Contains(got, "PathModel[User](\"id\", \"id\")") {
|
||||||
|
t.Fatalf("expected generated code to contain PathModel[User](\"id\", \"id\"), got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,14 @@
|
|||||||
- `@Router <path> [<method>]`
|
- `@Router <path> [<method>]`
|
||||||
- 例如:`@Router /users/:id [get]`
|
- 例如:`@Router /users/:id [get]`
|
||||||
|
|
||||||
- `@Bind <paramName> <position> [key(<key>)] [model(<model>)]`
|
- `@Bind <paramName> <position> [key(<key>)] [model(<field>|<model>[:<field>])]`
|
||||||
- `paramName` 必须与方法参数名一致(大小写敏感)
|
- `paramName` 必须与方法参数名一致(大小写敏感)
|
||||||
- `position` 取值:`path`、`query`、`body`、`header`、`cookie`、`local`、`file`
|
- `position` 取值:`path`、`query`、`body`、`header`、`cookie`、`local`、`file`
|
||||||
- 可选:`key()` 为覆盖默认键名;`model()` 解析并保留,当前不参与渲染(为后续扩展预留)
|
- 可选:
|
||||||
|
- `key()` 覆盖默认键名;
|
||||||
|
- `model()` 支持两种写法:
|
||||||
|
- 仅字段:`model(id)`(推荐;模型类型由参数类型推断)
|
||||||
|
- 类型+字段:`model(database/models.User:id)` 或 `model(database/models.User)`(字段缺省为 `id`)
|
||||||
|
|
||||||
### 参数来源与绑定生成
|
### 参数来源与绑定生成
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
- path
|
- path
|
||||||
- 标量类型:`PathParam[T]("key")`
|
- 标量类型:`PathParam[T]("key")`
|
||||||
- 非标量类型:`Path[T]("key")`
|
- 非标量类型:`Path[T]("key")`
|
||||||
|
- 若声明了 `model()` 且 position 为 `path`:生成 `PathModel[T]("field", "key")`,表示根据路径参数值按 `field` 查询并绑定为 `T` 实例(`T` 来自方法参数类型)。
|
||||||
- header:`Header[T]("key")`
|
- header:`Header[T]("key")`
|
||||||
- body:`Body[T]("key")`
|
- body:`Body[T]("key")`
|
||||||
- cookie
|
- cookie
|
||||||
@@ -63,12 +68,12 @@
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
// @Router /users/:id [get]
|
// @Router /users/:id [get]
|
||||||
// @Bind id path key(id)
|
// @Bind user path key(id) model(id)
|
||||||
// @Bind fields query
|
// @Bind fields query
|
||||||
// @Bind token header key(Authorization)
|
// @Bind token header key(Authorization)
|
||||||
// @Bind sess cookie key(session_id)
|
// @Bind sess cookie key(session_id)
|
||||||
// @Bind cfg local
|
// @Bind cfg local
|
||||||
func (uc *UserController) GetUser(ctx context.Context, id int64, fields []string, token string, sess string, cfg *AppConfig) (*User, error)
|
func (uc *UserController) GetUser(ctx context.Context, user *models.User, fields []string, token string, sess string, cfg *AppConfig) (*User, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
生成的路由注册(示意):
|
生成的路由注册(示意):
|
||||||
@@ -76,7 +81,7 @@ func (uc *UserController) GetUser(ctx context.Context, id int64, fields []string
|
|||||||
```go
|
```go
|
||||||
router.Get("/users/:id", DataFunc4(
|
router.Get("/users/:id", DataFunc4(
|
||||||
r.userController.GetUser,
|
r.userController.GetUser,
|
||||||
PathParam[int64]("id"),
|
PathModel[models.User]("id", "id"),
|
||||||
Query[[]string]("fields"),
|
Query[[]string]("fields"),
|
||||||
Header[string]("Authorization"),
|
Header[string]("Authorization"),
|
||||||
CookieParam("session_id"),
|
CookieParam("session_id"),
|
||||||
@@ -86,5 +91,5 @@ router.Get("/users/:id", DataFunc4(
|
|||||||
### 错误与限制
|
### 错误与限制
|
||||||
|
|
||||||
- 无效的 `@Router` 语法会报错;无效的 `position` 会在解析阶段触发错误。
|
- 无效的 `@Router` 语法会报错;无效的 `position` 会在解析阶段触发错误。
|
||||||
- `file` 目前仅支持单文件头;`model()` 尚未参与代码生成。
|
- `file` 目前仅支持单文件头;`model()` 仅在 `position=path` 时参与代码生成(使用 `PathModel`)。
|
||||||
- 请与实际路由段保持一致(特别是 `path` 的 `key` 与路径变量名)。
|
- 请与实际路由段保持一致(特别是 `path` 的 `key` 与路径变量名)。
|
||||||
|
|||||||
Reference in New Issue
Block a user