新增: @use 同文件片段复用
支持 @use("name") 引用同一文件内 @tpl 定义的块,
消除 _list/_count 模板中 WHERE 条件重复问题。
This commit is contained in:
@@ -15,6 +15,7 @@ type Executor struct {
|
|||||||
style PlaceholderStyle
|
style PlaceholderStyle
|
||||||
rawPolicy rawValidator
|
rawPolicy rawValidator
|
||||||
strict bool
|
strict bool
|
||||||
|
blocks map[string][]Node
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -22,11 +23,12 @@ type Result struct {
|
|||||||
Args []any
|
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{
|
return &Executor{
|
||||||
style: style,
|
style: style,
|
||||||
rawPolicy: rawPolicy,
|
rawPolicy: rawPolicy,
|
||||||
strict: strict,
|
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:
|
case *BlockNode, *NamespaceNode, *IncludeNode, *CommentNode:
|
||||||
// skip
|
// 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
|
return nil
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
TokTplStart
|
TokTplStart
|
||||||
TokIncludeStart
|
TokIncludeStart
|
||||||
TokNamespaceStart
|
TokNamespaceStart
|
||||||
|
TokUseStart
|
||||||
TokElse
|
TokElse
|
||||||
TokComment
|
TokComment
|
||||||
TokEOF
|
TokEOF
|
||||||
@@ -173,6 +174,7 @@ func (l *Lexer) tryDirective() (Token, bool) {
|
|||||||
{[]rune("@tpl(\""), TokTplStart, 5},
|
{[]rune("@tpl(\""), TokTplStart, 5},
|
||||||
{[]rune("@include(\""), TokIncludeStart, 10},
|
{[]rune("@include(\""), TokIncludeStart, 10},
|
||||||
{[]rune("@namespace(\""), TokNamespaceStart, 12},
|
{[]rune("@namespace(\""), TokNamespaceStart, 12},
|
||||||
|
{[]rune("@use(\""), TokUseStart, 6},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range directives {
|
for _, d := range directives {
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ type CommentNode struct {
|
|||||||
|
|
||||||
func (n *CommentNode) nodeType() string { return "Comment" }
|
func (n *CommentNode) nodeType() string { return "Comment" }
|
||||||
|
|
||||||
|
type UseNode struct {
|
||||||
|
Pos Pos
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *UseNode) nodeType() string { return "Use" }
|
||||||
|
|
||||||
type ExprType int
|
type ExprType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ func (p *Parser) Parse() ([]Node, error) {
|
|||||||
}
|
}
|
||||||
nodes = append(nodes, node)
|
nodes = append(nodes, node)
|
||||||
|
|
||||||
|
case TokUseStart:
|
||||||
|
node, err := p.parseUse(tok)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
|
||||||
case TokElse:
|
case TokElse:
|
||||||
return nil, fmt.Errorf("line %d, col %d: unexpected else", tok.Pos.Line, tok.Pos.Col)
|
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()
|
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) {
|
func (p *Parser) parseNamespace(tok Token, nodeCount int) (*NamespaceNode, error) {
|
||||||
if nodeCount > 0 {
|
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)
|
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
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
body = append(body, subNodes...)
|
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:
|
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)
|
return nil, nil, nil, fmt.Errorf("line %d, col %d: @namespace must be at file top level", tok.Pos.Line, tok.Pos.Col)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
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)
|
result, err := executor.Execute(nodes, vars)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapExecError(err)
|
return nil, wrapExecError(err)
|
||||||
|
|||||||
7
todo.md
7
todo.md
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# u-tpl
|
# u-tpl
|
||||||
|
|
||||||
实现一套 模板解析引擎,
|
实现一套 模板解析引擎,
|
||||||
@@ -6,8 +5,4 @@
|
|||||||
|
|
||||||
使用场景,现代 sql 模板管理,ai友好
|
使用场景,现代 sql 模板管理,ai友好
|
||||||
|
|
||||||
语言:golang
|
语言:golang
|
||||||
|
|
||||||
# 维护到 git 仓库, (暂时先不要提交,只暂缓到本地)
|
|
||||||
git remote add origin https://gitea.1216.top/lxy/u-tpl.git
|
|
||||||
git push -u origin main
|
|
||||||
|
|||||||
132
utpl_ext_test.go
132
utpl_ext_test.go
@@ -941,4 +941,136 @@ ORDER BY id`
|
|||||||
t.Errorf("SQL = %q, want :1/:2 placeholders", result.SQL)
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user