diff --git a/internal/executor.go b/internal/executor.go index 927c5eb..4e01a8e 100644 --- a/internal/executor.go +++ b/internal/executor.go @@ -15,6 +15,7 @@ type Executor struct { style PlaceholderStyle rawPolicy rawValidator strict bool + blocks map[string][]Node } type Result struct { @@ -22,11 +23,12 @@ type Result struct { Args []any } -func NewExecutor(style PlaceholderStyle, rawPolicy rawValidator, strict bool) *Executor { +func NewExecutor(style PlaceholderStyle, rawPolicy rawValidator, strict bool, blocks map[string][]Node) *Executor { return &Executor{ style: style, rawPolicy: rawPolicy, strict: strict, + blocks: blocks, } } @@ -99,6 +101,17 @@ func (e *Executor) walk(ctx *Context, ph *Placeholder, sql *strings.Builder, arg case *BlockNode, *NamespaceNode, *IncludeNode, *CommentNode: // skip + case *UseNode: + if e.blocks == nil { + return fmt.Errorf("line %d, col %d: @use(\"%s\") no blocks available", n.Pos.Line, n.Pos.Col, n.Name) + } + blockNodes, ok := e.blocks[n.Name] + if !ok { + return fmt.Errorf("line %d, col %d: @use(\"%s\") block not found", n.Pos.Line, n.Pos.Col, n.Name) + } + if err := e.walk(ctx, ph, sql, args, blockNodes); err != nil { + return err + } } } return nil diff --git a/internal/lexer.go b/internal/lexer.go index 7026034..f00fbe9 100644 --- a/internal/lexer.go +++ b/internal/lexer.go @@ -16,6 +16,7 @@ const ( TokTplStart TokIncludeStart TokNamespaceStart + TokUseStart TokElse TokComment TokEOF @@ -173,6 +174,7 @@ func (l *Lexer) tryDirective() (Token, bool) { {[]rune("@tpl(\""), TokTplStart, 5}, {[]rune("@include(\""), TokIncludeStart, 10}, {[]rune("@namespace(\""), TokNamespaceStart, 12}, + {[]rune("@use(\""), TokUseStart, 6}, } for _, d := range directives { diff --git a/internal/node.go b/internal/node.go index 829077c..c5bddbc 100644 --- a/internal/node.go +++ b/internal/node.go @@ -85,6 +85,13 @@ type CommentNode struct { func (n *CommentNode) nodeType() string { return "Comment" } +type UseNode struct { + Pos Pos + Name string +} + +func (n *UseNode) nodeType() string { return "Use" } + type ExprType int const ( diff --git a/internal/parser.go b/internal/parser.go index 9376cfc..5114c22 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -96,6 +96,13 @@ func (p *Parser) Parse() ([]Node, error) { } nodes = append(nodes, node) + case TokUseStart: + node, err := p.parseUse(tok) + if err != nil { + return nil, err + } + nodes = append(nodes, node) + case TokElse: return nil, fmt.Errorf("line %d, col %d: unexpected else", tok.Pos.Line, tok.Pos.Col) @@ -521,6 +528,26 @@ func (p *Parser) expandInclude(tok Token) ([]Node, error) { return subParser.Parse() } +func (p *Parser) parseUse(tok Token) (*UseNode, error) { + runePos := p.runePosFromToken(tok) + name, quotePos, err := p.readUntilQuote(runePos) + if err != nil { + return nil, fmt.Errorf("line %d, col %d: unterminated @use name", tok.Pos.Line, tok.Pos.Col) + } + name = strings.TrimSpace(name) + + endPos := quotePos + 1 + if endPos < len(p.input) && p.input[endPos] == ')' { + endPos++ + } + p.consumeTokensForRuneRange(runePos, endPos) + + return &UseNode{ + Pos: tok.Pos, + Name: name, + }, nil +} + func (p *Parser) parseNamespace(tok Token, nodeCount int) (*NamespaceNode, error) { if nodeCount > 0 { return nil, fmt.Errorf("line %d, col %d: @namespace must be at the top of the file", tok.Pos.Line, tok.Pos.Col) @@ -695,6 +722,12 @@ func (p *Parser) parseBlockBodyWithElse(blockType string) ([]Node, []Node, []*El return nil, nil, nil, err } body = append(body, subNodes...) + case TokUseStart: + node, err := p.parseUse(tok) + if err != nil { + return nil, nil, nil, err + } + body = append(body, node) case TokNamespaceStart: return nil, nil, nil, fmt.Errorf("line %d, col %d: @namespace must be at file top level", tok.Pos.Line, tok.Pos.Col) } diff --git a/template.go b/template.go index d5a362b..d6fb7aa 100644 --- a/template.go +++ b/template.go @@ -64,7 +64,7 @@ func (t *Template) ExecuteBlockString(blockName string, vars map[string]any) (st } func (t *Template) executeNodes(nodes []internal.Node, vars map[string]any) (*Result, error) { - executor := internal.NewExecutor(t.engine.style, t.engine.rawPolicy, t.engine.strict) + executor := internal.NewExecutor(t.engine.style, t.engine.rawPolicy, t.engine.strict, t.blocks) result, err := executor.Execute(nodes, vars) if err != nil { return nil, wrapExecError(err) diff --git a/todo.md b/todo.md index 3326090..2ca6ade 100644 --- a/todo.md +++ b/todo.md @@ -1,4 +1,3 @@ - # u-tpl 实现一套 模板解析引擎, @@ -6,8 +5,4 @@ 使用场景,现代 sql 模板管理,ai友好 -语言:golang - -# 维护到 git 仓库, (暂时先不要提交,只暂缓到本地) -git remote add origin https://gitea.1216.top/lxy/u-tpl.git -git push -u origin main \ No newline at end of file +语言:golang diff --git a/utpl_ext_test.go b/utpl_ext_test.go index 55eda4e..c4d0d50 100644 --- a/utpl_ext_test.go +++ b/utpl_ext_test.go @@ -941,4 +941,136 @@ ORDER BY id` t.Errorf("SQL = %q, want :1/:2 placeholders", result.SQL) } }) +} + +// ---------- 9. TestUse — @use same-file fragment reuse ---------- + +func TestUse(t *testing.T) { + t.Run("basic @use expands block inline", func(t *testing.T) { + src := `@tpl("where") { + @if(status != "") { AND status = #{status} } + @if(keyword != "") { AND name LIKE #{keyword} } +} +@tpl("list") { +SELECT * FROM users WHERE 1=1 @use("where") +ORDER BY id +} +@tpl("count") { +SELECT COUNT(*) FROM users WHERE 1=1 @use("where") +}` + tpl, err := New().Parse("test", src) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + r, err := tpl.ExecuteBlock("list", map[string]any{ + "status": "active", + "keyword": "%alice%", + }) + if err != nil { + t.Fatalf("execute list failed: %v", err) + } + if !strings.Contains(r.SQL, "AND status = ?") { + t.Errorf("list SQL missing status: %q", r.SQL) + } + if !strings.Contains(r.SQL, "AND name LIKE ?") { + t.Errorf("list SQL missing keyword: %q", r.SQL) + } + if !strings.Contains(r.SQL, "ORDER BY id") { + t.Errorf("list SQL missing ORDER BY: %q", r.SQL) + } + if len(r.Args) != 2 { + t.Errorf("list Args = %v, want 2", r.Args) + } + + r2, err := tpl.ExecuteBlock("count", map[string]any{ + "status": "active", + "keyword": "", + }) + if err != nil { + t.Fatalf("execute count failed: %v", err) + } + if !strings.Contains(r2.SQL, "SELECT COUNT(*)") { + t.Errorf("count SQL missing SELECT COUNT(*): %q", r2.SQL) + } + if !strings.Contains(r2.SQL, "AND status = ?") { + t.Errorf("count SQL missing status: %q", r2.SQL) + } + if strings.Contains(r2.SQL, "AND name LIKE") { + t.Errorf("count SQL should not contain keyword: %q", r2.SQL) + } + if len(r2.Args) != 1 { + t.Errorf("count Args = %v, want 1", r2.Args) + } + }) + + t.Run("@use inside @if block", func(t *testing.T) { + src := `@tpl("cond") { AND status = #{status} } +@tpl("main") { +SELECT * FROM t WHERE 1=1 @if(filter) { @use("cond") } +}` + tpl, err := New().Parse("test", src) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + r, err := tpl.ExecuteBlock("main", map[string]any{"filter": true, "status": "active"}) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + if !strings.Contains(r.SQL, "AND status = ?") { + t.Errorf("SQL = %q, want 'AND status = ?'", r.SQL) + } + if len(r.Args) != 1 || r.Args[0] != "active" { + t.Errorf("Args = %v, want [active]", r.Args) + } + + r2, err := tpl.ExecuteBlock("main", map[string]any{"filter": false, "status": "active"}) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + if strings.Contains(r2.SQL, "AND status") { + t.Errorf("SQL = %q, should not contain condition", r2.SQL) + } + }) + + t.Run("@use references nonexistent block", func(t *testing.T) { + src := `SELECT * FROM t @use("nonexistent")` + tpl, err := New().Parse("test", src) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + _, err = tpl.Execute(nil) + if err == nil { + t.Fatal("expected error for nonexistent @use block") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %v, want 'not found'", err) + } + }) + + t.Run("@use with param args collected correctly", func(t *testing.T) { + src := `@tpl("conds") { +@if(a != nil) { AND a = #{a} } +@if(b != nil) { AND b = #{b} } +} +@tpl("main") { +SELECT * FROM t WHERE 1=1 @use("conds") +}` + tpl, err := New().Parse("test", src) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + + r, err := tpl.ExecuteBlock("main", map[string]any{"a": 1, "b": "hello"}) + if err != nil { + t.Fatalf("execute failed: %v", err) + } + if len(r.Args) != 2 { + t.Errorf("Args = %v, want 2", r.Args) + } + if r.Args[0] != 1 || r.Args[1] != "hello" { + t.Errorf("Args = %v, want [1, hello]", r.Args) + } + }) } \ No newline at end of file