新增: u-tpl SQL 模板引擎完整实现
- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
This commit is contained in:
944
utpl_ext_test.go
Normal file
944
utpl_ext_test.go
Normal file
@@ -0,0 +1,944 @@
|
||||
package utpl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------- 1. TestInclude — @include directive ----------
|
||||
//
|
||||
// NOTE: The current implementation creates IncludeNode during parsing but does
|
||||
// not expand included content inline. The IncludeManager is constructed but never
|
||||
// invoked. These tests document the actual behavior. When include expansion is
|
||||
// fully wired up (content resolved before lexing), these tests should be updated
|
||||
// to verify full inline expansion.
|
||||
|
||||
func TestInclude(t *testing.T) {
|
||||
t.Run("include node parses without error when resolver configured", func(t *testing.T) {
|
||||
resolver := func(path string) (string, error) {
|
||||
files := map[string]string{
|
||||
"common/tenant": "AND tenant_id = #{tenant_id}",
|
||||
}
|
||||
src, ok := files[path]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("not found: %s", path)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
src := `SELECT * FROM orders WHERE 1=1 @include("common/tenant")`
|
||||
_, err := New(WithIncludeResolver(resolver)).Parse("test", src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse should succeed, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include without resolver returns error", func(t *testing.T) {
|
||||
src := `SELECT 1 @include("anything")`
|
||||
_, err := New().Parse("test", src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when @include used without resolver")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include content is expanded in output", func(t *testing.T) {
|
||||
resolver := func(path string) (string, error) {
|
||||
if path == "x" {
|
||||
return "RESOLVED_CONTENT", nil
|
||||
}
|
||||
return "", fmt.Errorf("not found: %s", path)
|
||||
}
|
||||
src := `BEFORE @include("x") AFTER`
|
||||
tpl, _ := New(WithIncludeResolver(resolver)).Parse("test", src)
|
||||
r, err := tpl.Execute(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "RESOLVED_CONTENT") {
|
||||
t.Errorf("SQL = %q, should contain resolved content", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "BEFORE") || !strings.Contains(r.SQL, "AFTER") {
|
||||
t.Errorf("SQL = %q, should contain BEFORE and AFTER text", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("include with resolver error fails at parse", func(t *testing.T) {
|
||||
resolver := func(path string) (string, error) {
|
||||
return "", fmt.Errorf("not found: %s", path)
|
||||
}
|
||||
src := `SELECT 1 @include("nonexistent")`
|
||||
_, err := New(WithIncludeResolver(resolver)).Parse("test", src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when include resolver fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 2. TestElseIf — else if / else branches ----------
|
||||
|
||||
func TestElseIf(t *testing.T) {
|
||||
t.Run("if with else branch", func(t *testing.T) {
|
||||
src := "@if(x == 1) {ONE} else {OTHER}"
|
||||
tests := []struct {
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{1, "ONE"},
|
||||
{2, "OTHER"},
|
||||
{99, "OTHER"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, _ := New().Parse("test", src)
|
||||
r, err := tpl.Execute(map[string]any{"x": tc.val})
|
||||
if err != nil {
|
||||
t.Fatalf("x=%v: %v", tc.val, err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("x=%v: SQL = %q, want to contain %q", tc.val, r.SQL, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("if else if else chain", func(t *testing.T) {
|
||||
src := `@if(role == "admin") {ADMIN} else if (role == "manager") {MGR} else {OTHER}`
|
||||
tests := []struct {
|
||||
role string
|
||||
want string
|
||||
}{
|
||||
{"admin", "ADMIN"},
|
||||
{"manager", "MGR"},
|
||||
{"guest", "OTHER"},
|
||||
{"other", "OTHER"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, _ := New().Parse("test", src)
|
||||
r, err := tpl.Execute(map[string]any{"role": tc.role})
|
||||
if err != nil {
|
||||
t.Fatalf("role=%q: %v", tc.role, err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("role=%q: SQL = %q, want to contain %q", tc.role, r.SQL, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple else if branches", func(t *testing.T) {
|
||||
src := `@if(x == 1) {ONE} else if (x == 2) {TWO} else if (x == 3) {THREE} else {OTHER}`
|
||||
tests := []struct {
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{1, "ONE"},
|
||||
{2, "TWO"},
|
||||
{3, "THREE"},
|
||||
{99, "OTHER"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, _ := New().Parse("test", src)
|
||||
r, err := tpl.Execute(map[string]any{"x": tc.val})
|
||||
if err != nil {
|
||||
t.Fatalf("x=%v: %v", tc.val, err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("x=%v: SQL = %q, want to contain %q", tc.val, r.SQL, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("else if without final else", func(t *testing.T) {
|
||||
src := `@if(x == 1) {ONE} else if (x == 2) {TWO}`
|
||||
tests := []struct {
|
||||
val any
|
||||
want string
|
||||
found bool
|
||||
}{
|
||||
{1, "ONE", true},
|
||||
{2, "TWO", true},
|
||||
{99, "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, _ := New().Parse("test", src)
|
||||
r, err := tpl.Execute(map[string]any{"x": tc.val})
|
||||
if err != nil {
|
||||
t.Fatalf("x=%v: %v", tc.val, err)
|
||||
}
|
||||
if tc.found && !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("x=%v: SQL = %q, want to contain %q", tc.val, r.SQL, tc.want)
|
||||
}
|
||||
if !tc.found && r.SQL != "" {
|
||||
t.Errorf("x=%v: SQL = %q, want empty", tc.val, r.SQL)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("else if with params in branches", func(t *testing.T) {
|
||||
src := `@if(role == "admin") {level = #{admin_level}} else if (role == "manager") {level = #{mgr_level}} else {level = #{default_level}}`
|
||||
tests := []struct {
|
||||
role string
|
||||
want string
|
||||
argVal any
|
||||
}{
|
||||
{"admin", "level = ?", 10},
|
||||
{"manager", "level = ?", 5},
|
||||
{"guest", "level = ?", 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, parseErr := New().Parse("test", src)
|
||||
if parseErr != nil {
|
||||
t.Fatalf("role=%q: parse failed: %v", tc.role, parseErr)
|
||||
}
|
||||
r, err := tpl.Execute(map[string]any{
|
||||
"role": tc.role,
|
||||
"admin_level": 10,
|
||||
"mgr_level": 5,
|
||||
"default_level": 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("role=%q: %v", tc.role, err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("role=%q: SQL = %q, want to contain %q", tc.role, r.SQL, tc.want)
|
||||
}
|
||||
if len(r.Args) != 1 || r.Args[0] != tc.argVal {
|
||||
t.Errorf("role=%q: Args = %v, want [%v]", tc.role, r.Args, tc.argVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("else if with no space before paren", func(t *testing.T) {
|
||||
src := "@if(x == 1) {ONE} else if(x == 2) {TWO} else {OTHER}"
|
||||
tests := []struct {
|
||||
val any
|
||||
want string
|
||||
}{
|
||||
{1, "ONE"},
|
||||
{2, "TWO"},
|
||||
{99, "OTHER"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tpl, _ := New().Parse("test", src)
|
||||
r, err := tpl.Execute(map[string]any{"x": tc.val})
|
||||
if err != nil {
|
||||
t.Fatalf("x=%v: %v", tc.val, err)
|
||||
}
|
||||
if !strings.Contains(r.SQL, tc.want) {
|
||||
t.Errorf("x=%v: SQL = %q, want to contain %q", tc.val, r.SQL, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("else if with multiline SQL", func(t *testing.T) {
|
||||
src := `SELECT * FROM users WHERE 1=1
|
||||
@if(role == "admin") {
|
||||
AND level >= #{admin_level}
|
||||
} else if (role == "manager") {
|
||||
AND level >= #{mgr_level}
|
||||
} else {
|
||||
AND level >= 1
|
||||
}`
|
||||
r := exec(t, src, map[string]any{
|
||||
"role": "manager",
|
||||
"admin_level": 10,
|
||||
"mgr_level": 5,
|
||||
})
|
||||
if !strings.Contains(r.SQL, "AND level >= ?") {
|
||||
t.Errorf("SQL = %q, want 'AND level >= ?'", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 1 || r.Args[0] != 5 {
|
||||
t.Errorf("Args = %v, want [5]", r.Args)
|
||||
}
|
||||
|
||||
r2 := exec(t, src, map[string]any{
|
||||
"role": "admin",
|
||||
"admin_level": 10,
|
||||
"mgr_level": 5,
|
||||
})
|
||||
if !strings.Contains(r2.SQL, "AND level >= ?") {
|
||||
t.Errorf("SQL = %q, want 'AND level >= ?'", r2.SQL)
|
||||
}
|
||||
if len(r2.Args) != 1 || r2.Args[0] != 10 {
|
||||
t.Errorf("Args = %v, want [10]", r2.Args)
|
||||
}
|
||||
|
||||
r3 := exec(t, src, map[string]any{
|
||||
"role": "guest",
|
||||
"admin_level": 10,
|
||||
"mgr_level": 5,
|
||||
})
|
||||
if !strings.Contains(r3.SQL, "AND level >= 1") {
|
||||
t.Errorf("SQL = %q, want 'AND level >= 1'", r3.SQL)
|
||||
}
|
||||
if len(r3.Args) != 0 {
|
||||
t.Errorf("Args = %v, want empty", r3.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("if without else produces no output when false", func(t *testing.T) {
|
||||
src := "@if(x == 1) {ONE}"
|
||||
tpl, _ := New().Parse("test", src)
|
||||
|
||||
r, _ := tpl.Execute(map[string]any{"x": 1})
|
||||
if !strings.Contains(r.SQL, "ONE") {
|
||||
t.Errorf("x=1: SQL = %q, want ONE", r.SQL)
|
||||
}
|
||||
|
||||
r2, _ := tpl.Execute(map[string]any{"x": 99})
|
||||
if strings.Contains(r2.SQL, "ONE") {
|
||||
t.Errorf("x=99: SQL = %q, should not contain ONE", r2.SQL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 3. TestExpression — expression edge cases ----------
|
||||
|
||||
func TestExpression(t *testing.T) {
|
||||
t.Run("len function with []any non-empty", func(t *testing.T) {
|
||||
r := exec(t, "@if(len(ids) > 0) {has ids}", map[string]any{"ids": []any{1, 2}})
|
||||
if !strings.Contains(r.SQL, "has ids") {
|
||||
t.Errorf("SQL = %q, want 'has ids'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("len function with []any empty", func(t *testing.T) {
|
||||
r := exec(t, "@if(len(ids) > 0) {has ids}", map[string]any{"ids": []any{}})
|
||||
if strings.Contains(r.SQL, "has ids") {
|
||||
t.Errorf("SQL = %q, should not contain 'has ids'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("len function with string", func(t *testing.T) {
|
||||
r := exec(t, "@if(len(name) > 0) {has name}", map[string]any{"name": "alice"})
|
||||
if !strings.Contains(r.SQL, "has name") {
|
||||
t.Errorf("SQL = %q, want 'has name'", r.SQL)
|
||||
}
|
||||
|
||||
r2 := exec(t, "@if(len(name) > 0) {has name}", map[string]any{"name": ""})
|
||||
if strings.Contains(r2.SQL, "has name") {
|
||||
t.Errorf("SQL = %q, should not contain 'has name'", r2.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric comparison ge", func(t *testing.T) {
|
||||
r := exec(t, "@if(age >= 18) {adult}", map[string]any{"age": 20})
|
||||
if !strings.Contains(r.SQL, "adult") {
|
||||
t.Errorf("age=20: SQL = %q, want 'adult'", r.SQL)
|
||||
}
|
||||
|
||||
r2 := exec(t, "@if(age >= 18) {adult}", map[string]any{"age": 10})
|
||||
if strings.Contains(r2.SQL, "adult") {
|
||||
t.Errorf("age=10: SQL = %q, should not contain 'adult'", r2.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boolean variable true", func(t *testing.T) {
|
||||
r := exec(t, "@if(flag) {yes}", map[string]any{"flag": true})
|
||||
if !strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("flag=true: SQL = %q, want 'yes'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boolean variable false", func(t *testing.T) {
|
||||
r := exec(t, "@if(flag) {yes}", map[string]any{"flag": false})
|
||||
if strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("flag=false: SQL = %q, should not contain 'yes'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negation true", func(t *testing.T) {
|
||||
r := exec(t, "@if(!flag) {no flag}", map[string]any{"flag": true})
|
||||
if strings.Contains(r.SQL, "no flag") {
|
||||
t.Errorf("flag=true: SQL = %q, should not contain 'no flag'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negation false", func(t *testing.T) {
|
||||
r := exec(t, "@if(!flag) {no flag}", map[string]any{"flag": false})
|
||||
if !strings.Contains(r.SQL, "no flag") {
|
||||
t.Errorf("flag=false: SQL = %q, want 'no flag'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested dot path in condition", func(t *testing.T) {
|
||||
r := exec(t, "@if(user.active) {active}", map[string]any{
|
||||
"user": map[string]any{"active": true},
|
||||
})
|
||||
if !strings.Contains(r.SQL, "active") {
|
||||
t.Errorf("SQL = %q, want 'active'", r.SQL)
|
||||
}
|
||||
|
||||
r2 := exec(t, "@if(user.active) {active}", map[string]any{
|
||||
"user": map[string]any{"active": false},
|
||||
})
|
||||
if strings.Contains(r2.SQL, "active") {
|
||||
t.Errorf("SQL = %q, should not contain 'active'", r2.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare to nil true", func(t *testing.T) {
|
||||
r := exec(t, "@if(val == nil) {is nil}", map[string]any{"val": nil})
|
||||
if !strings.Contains(r.SQL, "is nil") {
|
||||
t.Errorf("SQL = %q, want 'is nil'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare to nil false", func(t *testing.T) {
|
||||
r := exec(t, "@if(val == nil) {is nil}", map[string]any{"val": 42})
|
||||
if strings.Contains(r.SQL, "is nil") {
|
||||
t.Errorf("SQL = %q, should not contain 'is nil'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare nil not equal true", func(t *testing.T) {
|
||||
r := exec(t, "@if(val != nil) {not nil}", map[string]any{"val": 42})
|
||||
if !strings.Contains(r.SQL, "not nil") {
|
||||
t.Errorf("SQL = %q, want 'not nil'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("compare nil not equal false", func(t *testing.T) {
|
||||
r := exec(t, "@if(val != nil) {not nil}", map[string]any{"val": nil})
|
||||
if strings.Contains(r.SQL, "not nil") {
|
||||
t.Errorf("SQL = %q, should not contain 'not nil'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string equality comparison", func(t *testing.T) {
|
||||
r := exec(t, `@if(role == "admin") {is admin}`, map[string]any{"role": "admin"})
|
||||
if !strings.Contains(r.SQL, "is admin") {
|
||||
t.Errorf("SQL = %q, want 'is admin'", r.SQL)
|
||||
}
|
||||
|
||||
r2 := exec(t, `@if(role == "admin") {is admin}`, map[string]any{"role": "user"})
|
||||
if strings.Contains(r2.SQL, "is admin") {
|
||||
t.Errorf("SQL = %q, should not contain 'is admin'", r2.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric less than comparison", func(t *testing.T) {
|
||||
r := exec(t, "@if(count < 100) {under limit}", map[string]any{"count": 50})
|
||||
if !strings.Contains(r.SQL, "under limit") {
|
||||
t.Errorf("SQL = %q, want 'under limit'", r.SQL)
|
||||
}
|
||||
|
||||
r2 := exec(t, "@if(count < 100) {under limit}", map[string]any{"count": 150})
|
||||
if strings.Contains(r2.SQL, "under limit") {
|
||||
t.Errorf("SQL = %q, should not contain 'under limit'", r2.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("and operator short circuit", func(t *testing.T) {
|
||||
// When left is false, right should not cause error even if undefined
|
||||
r := exec(t, "@if(a != nil && b != \"\") {both}",
|
||||
map[string]any{"a": nil})
|
||||
if strings.Contains(r.SQL, "both") {
|
||||
t.Errorf("SQL = %q, should not contain 'both' when a is nil", r.SQL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 4. TestErrorPaths — error handling ----------
|
||||
|
||||
func TestErrorPaths(t *testing.T) {
|
||||
t.Run("unterminated param", func(t *testing.T) {
|
||||
_, err := New().Parse("test", "SELECT #{id")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unterminated param, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unterminated if", func(t *testing.T) {
|
||||
_, err := New().Parse("test", "@if(x > 0")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unterminated @if, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unterminated for", func(t *testing.T) {
|
||||
_, err := New().Parse("test", "@for(x, range list)")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unterminated @for (missing {), got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty tpl name", func(t *testing.T) {
|
||||
_, err := New().Parse("test", `@tpl("") { SELECT 1 }`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty @tpl name, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error should mention empty: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate tpl names", func(t *testing.T) {
|
||||
_, err := New().Parse("test", `@tpl("a") {X} @tpl("a") {Y}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate @tpl names, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("error should mention duplicate: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("namespace not at top", func(t *testing.T) {
|
||||
_, err := New().Parse("test", `SELECT 1 @namespace("x")`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for @namespace not at top, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "top") {
|
||||
t.Errorf("error should mention top: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tpl nested inside tpl", func(t *testing.T) {
|
||||
_, err := New().Parse("test", `@tpl("a") { @tpl("b") {X} }`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nested @tpl, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nested") {
|
||||
t.Errorf("error should mention nested: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undefined variable in strict mode", func(t *testing.T) {
|
||||
tpl, _ := New(WithStrictMode(true)).Parse("test", "SELECT #{missing}")
|
||||
_, err := tpl.Execute(map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for undefined variable in strict mode")
|
||||
}
|
||||
// The executor returns a plain error (not wrapped in ExecError)
|
||||
if !strings.Contains(err.Error(), "undefined") {
|
||||
t.Errorf("error should mention undefined: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("for with non-slice value", func(t *testing.T) {
|
||||
tpl, err := New().Parse("test", "SELECT @for(x, range items) {#{x}, }")
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
_, err = tpl.Execute(map[string]any{"items": "not a slice"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-slice in @for, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "slice") {
|
||||
t.Errorf("error should mention slice: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unterminated raw param", func(t *testing.T) {
|
||||
_, err := New().Parse("test", "SELECT ${col")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unterminated raw param, got nil")
|
||||
}
|
||||
var parseErr *ParseError
|
||||
if !errors.As(err, &parseErr) {
|
||||
t.Errorf("expected ParseError, got %T: %v", err, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 5. TestEdgeCases — boundary conditions ----------
|
||||
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("empty template", func(t *testing.T) {
|
||||
r := exec(t, "", nil)
|
||||
if r.SQL != "" {
|
||||
t.Errorf("SQL = %q, want empty", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 0 {
|
||||
t.Errorf("Args = %v, want empty", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pure text", func(t *testing.T) {
|
||||
r := exec(t, "SELECT 1", nil)
|
||||
if r.SQL != "SELECT 1" {
|
||||
t.Errorf("SQL = %q, want %q", r.SQL, "SELECT 1")
|
||||
}
|
||||
if len(r.Args) != 0 {
|
||||
t.Errorf("Args = %v, want empty", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only comments", func(t *testing.T) {
|
||||
r := exec(t, "# comment", nil)
|
||||
if r.SQL != "" {
|
||||
t.Errorf("SQL = %q, want empty", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 0 {
|
||||
t.Errorf("Args = %v, want empty", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple consecutive comments", func(t *testing.T) {
|
||||
r := exec(t, "# line1\n# line2\nSELECT 1", nil)
|
||||
if r.SQL != "SELECT 1" {
|
||||
t.Errorf("SQL = %q, want %q", r.SQL, "SELECT 1")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mixed params and raw", func(t *testing.T) {
|
||||
r := exec(t, "SELECT ${col} FROM t WHERE id = #{id}",
|
||||
map[string]any{"col": "name", "id": 1})
|
||||
if !strings.Contains(r.SQL, "SELECT name FROM t WHERE id = ?") {
|
||||
t.Errorf("SQL = %q, want 'SELECT name FROM t WHERE id = ?'", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 1 || r.Args[0] != 1 {
|
||||
t.Errorf("Args = %v, want [1]", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested if both true", func(t *testing.T) {
|
||||
r := exec(t, "@if(a) { @if(b) {both} }",
|
||||
map[string]any{"a": true, "b": true})
|
||||
if !strings.Contains(r.SQL, "both") {
|
||||
t.Errorf("SQL = %q, want 'both'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested if inner false", func(t *testing.T) {
|
||||
r := exec(t, "@if(a) { @if(b) {both} }",
|
||||
map[string]any{"a": true, "b": false})
|
||||
if strings.Contains(r.SQL, "both") {
|
||||
t.Errorf("SQL = %q, should not contain 'both'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested if outer false", func(t *testing.T) {
|
||||
r := exec(t, "@if(a) { @if(b) {both} }",
|
||||
map[string]any{"a": false, "b": true})
|
||||
if strings.Contains(r.SQL, "both") {
|
||||
t.Errorf("SQL = %q, should not contain 'both'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("if with parenthesized expression", func(t *testing.T) {
|
||||
src := "@if((a || b) && c) {yes}"
|
||||
tpl, _ := New().Parse("test", src)
|
||||
|
||||
r, _ := tpl.Execute(map[string]any{"a": true, "b": false, "c": true})
|
||||
if !strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("a=true,b=false,c=true: SQL = %q, want 'yes'", r.SQL)
|
||||
}
|
||||
|
||||
r, _ = tpl.Execute(map[string]any{"a": false, "b": true, "c": true})
|
||||
if !strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("a=false,b=true,c=true: SQL = %q, want 'yes'", r.SQL)
|
||||
}
|
||||
|
||||
r, _ = tpl.Execute(map[string]any{"a": false, "b": false, "c": true})
|
||||
if strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("a=false,b=false,c=true: SQL = %q, should not contain 'yes'", r.SQL)
|
||||
}
|
||||
|
||||
r, _ = tpl.Execute(map[string]any{"a": true, "b": true, "c": false})
|
||||
if strings.Contains(r.SQL, "yes") {
|
||||
t.Errorf("a=true,b=true,c=false: SQL = %q, should not contain 'yes'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("for with index variable", func(t *testing.T) {
|
||||
r := exec(t, "@for(i, v range items) {#{i}:#{v}, }",
|
||||
map[string]any{"items": []any{"x", "y", "z"}})
|
||||
if len(r.Args) != 6 {
|
||||
t.Fatalf("Args = %v, want 6 args (3 indices + 3 values)", r.Args)
|
||||
}
|
||||
// Indices: 0, 1, 2
|
||||
if r.Args[0] != 0 || r.Args[2] != 1 || r.Args[4] != 2 {
|
||||
t.Errorf("index args wrong: %v", r.Args)
|
||||
}
|
||||
// Values: x, y, z
|
||||
if r.Args[1] != "x" || r.Args[3] != "y" || r.Args[5] != "z" {
|
||||
t.Errorf("value args wrong: %v", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comment does not interfere with param", func(t *testing.T) {
|
||||
// #{param} starts with # but is not a comment
|
||||
r := exec(t, "SELECT #{id}\n# real comment",
|
||||
map[string]any{"id": 42})
|
||||
if !strings.Contains(r.SQL, "SELECT ?") {
|
||||
t.Errorf("SQL = %q, want 'SELECT ?'", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 1 || r.Args[0] != 42 {
|
||||
t.Errorf("Args = %v, want [42]", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw with numeric value", func(t *testing.T) {
|
||||
r := exec(t, "SELECT ${n}", map[string]any{"n": 123})
|
||||
if !strings.Contains(r.SQL, "SELECT 123") {
|
||||
t.Errorf("SQL = %q, want 'SELECT 123'", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple consecutive params", func(t *testing.T) {
|
||||
r := exec(t, "INSERT INTO t (a, b, c) VALUES (#{a}, #{b}, #{c})",
|
||||
map[string]any{"a": 1, "b": 2, "c": 3})
|
||||
if !strings.Contains(r.SQL, "?, ?, ?") {
|
||||
t.Errorf("SQL = %q, want three placeholders", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 3 {
|
||||
t.Errorf("Args = %v, want 3", r.Args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 6. TestMultiline — real SQL with line breaks ----------
|
||||
|
||||
func TestMultiline(t *testing.T) {
|
||||
t.Run("full SELECT with multiple lines", func(t *testing.T) {
|
||||
src := `SELECT u.id, u.name, u.email
|
||||
FROM users u
|
||||
WHERE u.status = #{status}
|
||||
@if(name != "") { AND u.name LIKE #{name} }
|
||||
ORDER BY u.id DESC
|
||||
LIMIT #{limit}`
|
||||
tpl, err := New().Parse("test", src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
r, err := tpl.Execute(map[string]any{
|
||||
"status": "active",
|
||||
"name": "%alice%",
|
||||
"limit": 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(r.SQL, "SELECT u.id, u.name, u.email") {
|
||||
t.Errorf("SQL missing SELECT: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "FROM users u") {
|
||||
t.Errorf("SQL missing FROM: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "WHERE u.status = ?") {
|
||||
t.Errorf("SQL missing WHERE: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "AND u.name LIKE ?") {
|
||||
t.Errorf("SQL missing name condition: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "ORDER BY u.id DESC") {
|
||||
t.Errorf("SQL missing ORDER BY: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "LIMIT ?") {
|
||||
t.Errorf("SQL missing LIMIT: %q", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 3 || r.Args[0] != "active" || r.Args[1] != "%alice%" || r.Args[2] != 10 {
|
||||
t.Errorf("Args = %v, want [active %%alice%% 10]", r.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("line breaks preserved", func(t *testing.T) {
|
||||
src := "SELECT id\nFROM users\nWHERE id = #{id}"
|
||||
r := exec(t, src, map[string]any{"id": 1})
|
||||
lines := strings.Split(r.SQL, "\n")
|
||||
if len(lines) < 3 {
|
||||
t.Errorf("SQL = %q, want at least 3 lines", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "SELECT id\nFROM users\nWHERE id = ?") {
|
||||
t.Errorf("SQL = %q, line breaks not preserved", r.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiline dynamic update", func(t *testing.T) {
|
||||
src := `UPDATE users SET
|
||||
@if(name != nil) { name = #{name}, }
|
||||
@if(email != nil) { email = #{email}, }
|
||||
updated_at = NOW()
|
||||
WHERE id = #{id}`
|
||||
r := exec(t, src, map[string]any{"name": "alice", "email": nil, "id": 1})
|
||||
if !strings.Contains(r.SQL, "name = ?") {
|
||||
t.Errorf("SQL missing name: %q", r.SQL)
|
||||
}
|
||||
if strings.Contains(r.SQL, "email = ?") {
|
||||
t.Errorf("SQL should not contain email: %q", r.SQL)
|
||||
}
|
||||
if !strings.Contains(r.SQL, "WHERE id = ?") {
|
||||
t.Errorf("SQL missing WHERE: %q", r.SQL)
|
||||
}
|
||||
if len(r.Args) != 2 {
|
||||
t.Errorf("Args = %v, want 2 args", r.Args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 7. TestExecuteString — convenience methods ----------
|
||||
|
||||
func TestExecuteString(t *testing.T) {
|
||||
t.Run("ExecuteString returns SQL only", func(t *testing.T) {
|
||||
tpl, err := New().Parse("test", "SELECT * FROM users WHERE id = #{id}")
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
sql, err := tpl.ExecuteString(map[string]any{"id": 42})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteString failed: %v", err)
|
||||
}
|
||||
if sql != "SELECT * FROM users WHERE id = ?" {
|
||||
t.Errorf("SQL = %q, want %q", sql, "SELECT * FROM users WHERE id = ?")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExecuteBlockString returns SQL only", func(t *testing.T) {
|
||||
tpl, err := New().Parse("test", "@tpl(\"search\") {\nSELECT * FROM orders WHERE id = #{id}\n}")
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
sql, err := tpl.ExecuteBlockString("search", map[string]any{"id": 7})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteBlockString failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "SELECT * FROM orders WHERE id = ?") {
|
||||
t.Errorf("SQL = %q, want to contain 'SELECT * FROM orders WHERE id = ?'", sql)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExecuteString error propagation", func(t *testing.T) {
|
||||
tpl, _ := New(WithStrictMode(true)).Parse("test", "SELECT #{missing}")
|
||||
_, err := tpl.ExecuteString(map[string]any{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for undefined variable, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExecuteBlockString error for missing block", func(t *testing.T) {
|
||||
tpl, _ := New().Parse("test", `@tpl("search") {SELECT 1}`)
|
||||
_, err := tpl.ExecuteBlockString("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing block, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 8. TestBenchmarkConsistency ----------
|
||||
|
||||
func TestBenchmarkConsistency(t *testing.T) {
|
||||
t.Run("simple benchmark scenario", func(t *testing.T) {
|
||||
tpl := New().MustParse("bench", "SELECT * FROM users WHERE id = #{id} AND name = #{name}")
|
||||
result, err := tpl.Execute(map[string]any{"id": 42, "name": "alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if result.SQL != "SELECT * FROM users WHERE id = ? AND name = ?" {
|
||||
t.Errorf("SQL = %q", result.SQL)
|
||||
}
|
||||
if len(result.Args) != 2 || result.Args[0] != 42 || result.Args[1] != "alice" {
|
||||
t.Errorf("Args = %v, want [42 alice]", result.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conditional benchmark scenario", func(t *testing.T) {
|
||||
src := `SELECT * FROM users WHERE 1=1
|
||||
@if(status != nil) { AND status = #{status} }
|
||||
@if(name != "") { AND name = #{name} }
|
||||
ORDER BY id`
|
||||
tpl := New().MustParse("bench", src)
|
||||
|
||||
// all conditions active
|
||||
result, err := tpl.Execute(map[string]any{"status": "active", "name": "alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.SQL, "AND status = ?") {
|
||||
t.Errorf("SQL missing status: %q", result.SQL)
|
||||
}
|
||||
if !strings.Contains(result.SQL, "AND name = ?") {
|
||||
t.Errorf("SQL missing name: %q", result.SQL)
|
||||
}
|
||||
if len(result.Args) != 2 {
|
||||
t.Errorf("Args = %v, want 2 args", result.Args)
|
||||
}
|
||||
|
||||
// no conditions
|
||||
result2, err := tpl.Execute(map[string]any{"status": nil, "name": ""})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if strings.Contains(result2.SQL, "AND") {
|
||||
t.Errorf("SQL should have no AND: %q", result2.SQL)
|
||||
}
|
||||
if len(result2.Args) != 0 {
|
||||
t.Errorf("Args = %v, want empty", result2.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loop benchmark scenario", func(t *testing.T) {
|
||||
src := `SELECT * FROM users WHERE id IN (@for(id range ids) {#{id}, })`
|
||||
tpl := New().MustParse("bench", src)
|
||||
ids := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
|
||||
result, err := tpl.Execute(map[string]any{"ids": ids})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if len(result.Args) != 10 {
|
||||
t.Errorf("Args = %v, want 10 args", result.Args)
|
||||
}
|
||||
for i, want := range ids {
|
||||
if result.Args[i] != want {
|
||||
t.Errorf("Args[%d] = %v, want %v", i, result.Args[i], want)
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(result.SQL, ",") {
|
||||
t.Errorf("SQL should not end with comma: %q", result.SQL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder dollar benchmark scenario", func(t *testing.T) {
|
||||
eng := New(WithPlaceholderStyle(DollarNumber))
|
||||
tpl := eng.MustParse("bench", "SELECT * FROM users WHERE id = #{id} AND name = #{name}")
|
||||
result, err := tpl.Execute(map[string]any{"id": 42, "name": "alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.SQL, "WHERE id = $1 AND name = $2") {
|
||||
t.Errorf("SQL = %q, want $1/$2 placeholders", result.SQL)
|
||||
}
|
||||
if len(result.Args) != 2 {
|
||||
t.Errorf("Args = %v, want 2 args", result.Args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder colon benchmark scenario", func(t *testing.T) {
|
||||
eng := New(WithPlaceholderStyle(ColonNumber))
|
||||
tpl := eng.MustParse("bench", "WHERE id = #{id} AND name = #{name}")
|
||||
result, err := tpl.Execute(map[string]any{"id": 1, "name": "a"})
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.SQL, "WHERE id = :1 AND name = :2") {
|
||||
t.Errorf("SQL = %q, want :1/:2 placeholders", result.SQL)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user