diff --git a/pkg/ast/route/builder.go b/pkg/ast/route/builder.go index 39836ba..7a01d32 100644 --- a/pkg/ast/route/builder.go +++ b/pkg/ast/route/builder.go @@ -85,31 +85,41 @@ func buildRenderData(opts RenderBuildOpts) (RenderData, error) { } func buildParamToken(item ParamDefinition) string { - key := item.Name - if item.Key != "" { - key = item.Key - } + key := item.Name + if item.Key != "" { + key = item.Key + } - switch item.Position { - case PositionQuery: - return fmt.Sprintf(`Query%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key) - case PositionHeader: - return fmt.Sprintf(`Header[%s]("%s")`, item.Type, key) - case PositionFile: - return fmt.Sprintf(`File[multipart.FileHeader]("%s")`, key) - case PositionCookie: - if item.Type == "string" { - return fmt.Sprintf(`CookieParam("%s")`, key) - } - return fmt.Sprintf(`Cookie[%s]("%s")`, item.Type, key) - case PositionBody: - return fmt.Sprintf(`Body[%s]("%s")`, item.Type, key) - case PositionPath: - 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 "" + switch item.Position { + case PositionQuery: + return fmt.Sprintf(`Query%s[%s]("%s")`, scalarSuffix(item.Type), item.Type, key) + case PositionHeader: + return fmt.Sprintf(`Header[%s]("%s")`, item.Type, key) + case PositionFile: + return fmt.Sprintf(`File[multipart.FileHeader]("%s")`, key) + case PositionCookie: + if item.Type == "string" { + return fmt.Sprintf(`CookieParam("%s")`, key) + } + return fmt.Sprintf(`Cookie[%s]("%s")`, item.Type, key) + case PositionBody: + return fmt.Sprintf(`Body[%s]("%s")`, item.Type, key) + case PositionPath: + // If a model field is specified, generate a model-lookup binder from path value. + if item.ModelField != "" || item.Model != "" { + field := item.ModelField + if field == "" { + 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 { diff --git a/pkg/ast/route/route.go b/pkg/ast/route/route.go index 2473b7b..3d2927e 100644 --- a/pkg/ast/route/route.go +++ b/pkg/ast/route/route.go @@ -33,6 +33,9 @@ type ParamDefinition struct { Type string Key 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 } @@ -138,9 +141,9 @@ func ParseFile(file string) []RouteDefinition { } if strings.HasPrefix(line, "@Bind") { - //@Bind name [uri|query|path|body|header|cookie] [key()] [table()] [model()] - bindParams = append(bindParams, parseRouteBind(line)) - } + //@Bind name [uri|query|path|body|header|cookie] [key()] [table()] [model(.[:])] + bindParams = append(bindParams, parseRouteBind(line)) + } } if path == "" || method == "" { @@ -236,24 +239,44 @@ func parseRouteComment(line string) (string, string, error) { } func parseRouteBind(bind string) ParamDefinition { - var param ParamDefinition - parts := strings.FieldsFunc(bind, func(r rune) bool { - return r == ' ' || r == '(' || r == ')' || r == '\t' - }) - parts = lo.Filter(parts, func(item string, idx int) bool { - return item != "" - }) + var param ParamDefinition + parts := strings.FieldsFunc(bind, func(r rune) bool { + return r == ' ' || r == '(' || r == ')' || r == '\t' + }) + parts = lo.Filter(parts, func(item string, idx int) bool { + return item != "" + }) - for i, part := range parts { - switch part { - case "@Bind": - param.Name = parts[i+1] - param.Position = positionFromString(parts[i+2]) - case "key": - param.Key = parts[i+1] + for i, part := range parts { + switch part { + case "@Bind": + param.Name = parts[i+1] + param.Position = positionFromString(parts[i+2]) + case "key": + param.Key = parts[i+1] 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 } diff --git a/pkg/ast/route/route_model_bind_test.go b/pkg/ast/route/route_model_bind_test.go new file mode 100644 index 0000000..ec41fac --- /dev/null +++ b/pkg/ast/route/route_model_bind_test.go @@ -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) + } +} diff --git a/templates/project/README.md.raw b/templates/project/README.md.raw index 1cd18ce..2f0b683 100644 --- a/templates/project/README.md.raw +++ b/templates/project/README.md.raw @@ -10,10 +10,14 @@ - `@Router []` - 例如:`@Router /users/:id [get]` - - `@Bind [key()] [model()]` + - `@Bind [key()] [model(|[:])]` - `paramName` 必须与方法参数名一致(大小写敏感) - `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 - 标量类型:`PathParam[T]("key")` - 非标量类型:`Path[T]("key")` + - 若声明了 `model()` 且 position 为 `path`:生成 `PathModel[T]("field", "key")`,表示根据路径参数值按 `field` 查询并绑定为 `T` 实例(`T` 来自方法参数类型)。 - header:`Header[T]("key")` - body:`Body[T]("key")` - cookie @@ -63,12 +68,12 @@ ```go // @Router /users/:id [get] -// @Bind id path key(id) +// @Bind user path key(id) model(id) // @Bind fields query // @Bind token header key(Authorization) // @Bind sess cookie key(session_id) // @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 router.Get("/users/:id", DataFunc4( r.userController.GetUser, - PathParam[int64]("id"), + PathModel[models.User]("id", "id"), Query[[]string]("fields"), Header[string]("Authorization"), CookieParam("session_id"), @@ -86,5 +91,5 @@ router.Get("/users/:id", DataFunc4( ### 错误与限制 - 无效的 `@Router` 语法会报错;无效的 `position` 会在解析阶段触发错误。 -- `file` 目前仅支持单文件头;`model()` 尚未参与代码生成。 +- `file` 目前仅支持单文件头;`model()` 仅在 `position=path` 时参与代码生成(使用 `PathModel`)。 - 请与实际路由段保持一致(特别是 `path` 的 `key` 与路径变量名)。