Skip to content

Commit 7fd10b8

Browse files
x5iuclaude
andauthored
✨ Add constbind option for sqlx mode (#2)
Add a new `CONSTBIND` option that allows using `${expr}` syntax for compile-time parameter binding without template rendering overhead. Features: - Extract `${...}` expressions from SQL and replace with `?` placeholders - Support any Go expression including variables, struct fields, and function calls - Properly handle quoted strings (expressions inside quotes are preserved) - Support nested braces for complex expressions like `${map[string]int{}}` Example usage: ```go // GetUser query constbind // SELECT * FROM user WHERE name = ${user.Name} AND age > ${user.Age}; GetUser(ctx context.Context, user *User) (*User, error) ``` Changes: - gen/method.go: Add parseConstBindExpressions() function - gen/sqlx.go: Add constBindSQL and constBindArgs template functions - gen/template/sqlx.tmpl: Support CONSTBIND option in template - gen/method_test.go: Add unit tests for expression parsing - gen/sqlx_test.go: Add build test for constbind - gen/integration/sqlx/main.go: Add integration tests - README.md: Document the new feature - CLAUDE.md: Update with constbind information 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fa5abee commit 7fd10b8

10 files changed

Lines changed: 352 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ defc --mode=rpc --output=client.go
130130
- `Options()`: Configuration provider in api mode
131131
- `ResponseHandler()`: Response processing in api mode
132132
- Template functions like `bind`, `bindvars`, `getRepr` are available in SQL templates
133+
- **Sqlx method options**: `CONST`, `CONSTBIND`, `BIND`, `NAMED`, `ONE`, `MANY`, `SCAN(expr)`, `WRAP=func`, `ISOLATION=level`, `ARGUMENTS=varname`
134+
- `CONSTBIND`: Uses `${expr}` syntax for compile-time parameter binding without template rendering (gen/method.go:261-334)
133135
- Test files use `runTest()` helper function for consistent integration testing
134136
- RPC specifics:
135137
- Methods must have exactly 1 input parameter and 2 outputs, with the second being `error` (gen/rpc.go:35-49)

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ MethodName(ctx context.Context, params...) (result, error)
296296
- `MANY`: Use `sqlx.Select` for multiple results
297297
- `ONE`: Use `sqlx.Get` for single result
298298
- `CONST`: Disable template processing for better performance
299+
- `CONSTBIND`: Use `${expr}` syntax for compile-time parameter binding without template rendering
299300
- `BIND`: Use binding mode for parameters
300301
- `SCAN(expr)`: Custom scan target
301302
- `WRAP=func`: Wrap the query with a custom function
@@ -573,6 +574,34 @@ FindUsers(ctx context.Context, name string, minAge int) ([]*User, error)
573574
}
574575
```
575576

577+
#### Const Bind Mode
578+
579+
The `CONSTBIND` option provides a simple way to bind Go expressions directly in SQL without template rendering overhead.
580+
Use `${expr}` syntax to reference variables or expressions:
581+
582+
```go
583+
//go:generate go run -mod=mod "github.com/x5iu/defc" --mode=sqlx --output=user_query.go
584+
type UserQuery interface {
585+
// GetUserByName QUERY CONSTBIND
586+
// SELECT * FROM users WHERE name = ${name} AND age > ${minAge};
587+
GetUserByName(ctx context.Context, name string, minAge int) (*User, error)
588+
589+
// UpdateUser EXEC CONSTBIND
590+
// UPDATE users SET name = ${user.Name}, age = ${user.Age} WHERE id = ${user.ID};
591+
UpdateUser(ctx context.Context, user *User) error
592+
593+
// GetUsersByStatus QUERY CONSTBIND
594+
// SELECT * FROM users WHERE status = ${status} AND created_at > ${time.Now().Add(-24 * time.Hour)};
595+
GetUsersByStatus(ctx context.Context, status string) ([]*User, error)
596+
}
597+
```
598+
599+
**Key differences from other modes:**
600+
601+
- `CONST`: SQL is used as-is, parameters must use `?` placeholders manually
602+
- `CONSTBIND`: SQL uses `${expr}` syntax, automatically converted to `?` placeholders with expressions as arguments
603+
- `BIND`: Uses Go template with `{{ bind $.var }}` syntax, rendered at runtime
604+
576605
#### Transaction Support
577606

578607
```go

gen/integration/sqlx/main.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,50 @@ func main() {
114114
if !reflect.DeepEqual(userIDs, UserIDs{{1}, {4}}) {
115115
log.Fatalf("unexpected userIDs: %v\n", userIDs)
116116
}
117+
118+
// Test constbind: GetUserByName
119+
userByName, err := executor.GetUserByName(ctx, "defc_test_0001")
120+
if err != nil {
121+
log.Fatalln(err)
122+
}
123+
if userByName.id != 1 || userByName.name != "defc_test_0001" {
124+
log.Fatalf("unexpected user from GetUserByName: User(id=%d, name=%q)\n",
125+
userByName.id,
126+
userByName.name)
127+
}
128+
129+
// Test constbind: QueryUsersByNameAndID
130+
usersByPattern, err := executor.QueryUsersByNameAndID(ctx, "defc_test_000%", 2)
131+
if err != nil {
132+
log.Fatalln(err)
133+
}
134+
if len(usersByPattern) != 3 {
135+
log.Fatalf("expected 3 users from QueryUsersByNameAndID, got %d\n", len(usersByPattern))
136+
}
137+
// Should get users with id > 2: defc_test_0003, defc_test_0004, defc_test_0005
138+
expectedIDs := []int64{3, 4, 5}
139+
for i, u := range usersByPattern {
140+
if u.id != expectedIDs[i] {
141+
log.Fatalf("unexpected user id at index %d: expected %d, got %d\n", i, expectedIDs[i], u.id)
142+
}
143+
}
144+
145+
// Test constbind: UpdateUserName
146+
_, err = executor.UpdateUserName(ctx, 1, "defc_test_updated")
147+
if err != nil {
148+
log.Fatalln(err)
149+
}
150+
updatedUser, err := executor.GetUserByName(ctx, "defc_test_updated")
151+
if err != nil {
152+
log.Fatalln(err)
153+
}
154+
if updatedUser.id != 1 || updatedUser.name != "defc_test_updated" {
155+
log.Fatalf("unexpected updated user: User(id=%d, name=%q)\n",
156+
updatedUser.id,
157+
updatedUser.name)
158+
}
159+
160+
log.Println("All constbind tests passed!")
117161
}
118162

119163
type sqlc struct {
@@ -213,6 +257,21 @@ type Executor interface {
213257
// /* {"name": "defc", "action": "test"} */
214258
// select id, name, user_id from project where user_id = ? and id != 0 order by id asc;
215259
GetProjectsByUserID(userID int64) ([]*Project, error)
260+
261+
// GetUserByName query constbind
262+
// /* {"name": "defc", "action": "test"} */
263+
// select id, name from user where name = ${name};
264+
GetUserByName(ctx context.Context, name string) (*User, error)
265+
266+
// QueryUsersByNameAndID query constbind
267+
// /* {"name": "defc", "action": "test"} */
268+
// select id, name from user where name like ${pattern} and id > ${minID} order by id asc;
269+
QueryUsersByNameAndID(ctx context.Context, pattern string, minID int64) ([]*User, error)
270+
271+
// UpdateUserName exec constbind
272+
// /* {"name": "defc", "action": "test"} */
273+
// update user set name = ${newName} where id = ${id};
274+
UpdateUserName(ctx context.Context, id int64, newName string) (sql.Result, error)
216275
}
217276

218277
type UserID struct {

gen/method.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,87 @@ func (method *Method) ArgumentsVar() string {
258258
return ""
259259
}
260260

261+
// ConstBindResult represents the result of parsing constbind expressions
262+
type ConstBindResult struct {
263+
SQL string // SQL with ${...} replaced by ?
264+
Args []string // extracted expressions
265+
}
266+
267+
// ParseConstBind parses the Header and extracts ${...} expressions,
268+
// replacing them with ? placeholders. This is used with CONSTBIND option.
269+
func (method *Method) ParseConstBind() (ConstBindResult, error) {
270+
return parseConstBindExpressions(method.Header)
271+
}
272+
273+
// parseConstBindExpressions extracts ${...} expressions from the input string
274+
// and replaces them with ? placeholders.
275+
func parseConstBindExpressions(input string) (ConstBindResult, error) {
276+
var (
277+
result []byte
278+
args []string
279+
i int
280+
singleQuoted bool
281+
doubleQuoted bool
282+
)
283+
284+
for i < len(input) {
285+
ch := input[i]
286+
287+
// Handle quote tracking (to avoid replacing ${...} inside string literals)
288+
if ch == '\'' && !doubleQuoted && (i == 0 || input[i-1] != '\\') {
289+
singleQuoted = !singleQuoted
290+
result = append(result, ch)
291+
i++
292+
continue
293+
}
294+
if ch == '"' && !singleQuoted && (i == 0 || input[i-1] != '\\') {
295+
doubleQuoted = !doubleQuoted
296+
result = append(result, ch)
297+
i++
298+
continue
299+
}
300+
301+
// Look for ${ pattern outside of quotes
302+
if !singleQuoted && !doubleQuoted && ch == '$' && i+1 < len(input) && input[i+1] == '{' {
303+
startPos := i
304+
// Find the matching }
305+
depth := 1
306+
j := i + 2
307+
for j < len(input) && depth > 0 {
308+
switch input[j] {
309+
case '{':
310+
depth++
311+
case '}':
312+
depth--
313+
}
314+
j++
315+
}
316+
317+
if depth != 0 {
318+
return ConstBindResult{}, fmt.Errorf("unclosed expression starting at position %d: %s", startPos, input[startPos:])
319+
}
320+
321+
// Extract the expression (without ${ and })
322+
expr := trimSpace(input[i+2 : j-1])
323+
if expr == "" {
324+
return ConstBindResult{}, fmt.Errorf("empty expression at position %d", startPos)
325+
}
326+
args = append(args, expr)
327+
result = append(result, '?')
328+
i = j
329+
continue
330+
}
331+
332+
result = append(result, ch)
333+
i++
334+
}
335+
336+
return ConstBindResult{
337+
SQL: string(result),
338+
Args: args,
339+
}, nil
340+
}
341+
261342
// ReturnSlice should only be used with '--mode=api' arg
262343
func (method *Method) ReturnSlice() bool {
263344
if args := method.MetaArgs(); len(args) >= 3 {

gen/method_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,116 @@ func TestMethod(t *testing.T) {
6565
return
6666
}
6767
}
68+
69+
func TestParseConstBindExpressions(t *testing.T) {
70+
tests := []struct {
71+
name string
72+
input string
73+
wantSQL string
74+
wantArgs []string
75+
wantErr bool
76+
}{
77+
{
78+
name: "simple expression",
79+
input: "SELECT * FROM user WHERE username = ${user.Name}",
80+
wantSQL: "SELECT * FROM user WHERE username = ?",
81+
wantArgs: []string{"user.Name"},
82+
},
83+
{
84+
name: "multiple expressions",
85+
input: "SELECT * FROM user WHERE username = ${user.Name} AND age > ${user.Age}",
86+
wantSQL: "SELECT * FROM user WHERE username = ? AND age > ?",
87+
wantArgs: []string{"user.Name", "user.Age"},
88+
},
89+
{
90+
name: "simple variable expression",
91+
input: "SELECT * FROM user WHERE name = ${name}",
92+
wantSQL: "SELECT * FROM user WHERE name = ?",
93+
wantArgs: []string{"name"},
94+
},
95+
{
96+
name: "expression inside sql string literal should not be replaced",
97+
input: "SELECT * FROM user WHERE status = '${literal}'",
98+
wantSQL: "SELECT * FROM user WHERE status = '${literal}'",
99+
wantArgs: []string{},
100+
},
101+
{
102+
name: "expression inside double quoted string should not be replaced",
103+
input: `SELECT * FROM user WHERE status = "${literal}"`,
104+
wantSQL: `SELECT * FROM user WHERE status = "${literal}"`,
105+
wantArgs: []string{},
106+
},
107+
{
108+
name: "nested braces in expression",
109+
input: "SELECT * FROM user WHERE data = ${map[string]int{}}",
110+
wantSQL: "SELECT * FROM user WHERE data = ?",
111+
wantArgs: []string{"map[string]int{}"},
112+
},
113+
{
114+
name: "no expressions",
115+
input: "SELECT * FROM user",
116+
wantSQL: "SELECT * FROM user",
117+
wantArgs: []string{},
118+
},
119+
{
120+
name: "unclosed expression",
121+
input: "SELECT * FROM user WHERE id = ${name",
122+
wantSQL: "",
123+
wantArgs: nil,
124+
wantErr: true,
125+
},
126+
{
127+
name: "empty expression",
128+
input: "SELECT * FROM user WHERE id = ${}",
129+
wantSQL: "",
130+
wantArgs: nil,
131+
wantErr: true,
132+
},
133+
{
134+
name: "expression with spaces",
135+
input: "SELECT * FROM user WHERE id = ${ user.ID }",
136+
wantSQL: "SELECT * FROM user WHERE id = ?",
137+
wantArgs: []string{"user.ID"},
138+
},
139+
{
140+
name: "expression with function call",
141+
input: "SELECT * FROM user WHERE created_at > ${time.Now().Add(-24 * time.Hour)}",
142+
wantSQL: "SELECT * FROM user WHERE created_at > ?",
143+
wantArgs: []string{"time.Now().Add(-24 * time.Hour)"},
144+
},
145+
{
146+
name: "mixed quoted and unquoted",
147+
input: "SELECT * FROM user WHERE name = ${name} AND status = 'active' AND age = ${age}",
148+
wantSQL: "SELECT * FROM user WHERE name = ? AND status = 'active' AND age = ?",
149+
wantArgs: []string{"name", "age"},
150+
},
151+
{
152+
name: "literal expression",
153+
input: "SELECT * FROM user WHERE name = ${\"X\"} AND status = 'active' AND age = ${18}",
154+
wantSQL: "SELECT * FROM user WHERE name = ? AND status = 'active' AND age = ?",
155+
wantArgs: []string{"\"X\"", "18"},
156+
},
157+
}
158+
159+
for _, tt := range tests {
160+
t.Run(tt.name, func(t *testing.T) {
161+
result, err := parseConstBindExpressions(tt.input)
162+
if (err != nil) != tt.wantErr {
163+
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
164+
return
165+
}
166+
if tt.wantErr {
167+
return
168+
}
169+
if result.SQL != tt.wantSQL {
170+
t.Errorf("SQL = %q, want %q", result.SQL, tt.wantSQL)
171+
}
172+
if len(result.Args) == 0 && len(tt.wantArgs) == 0 {
173+
return
174+
}
175+
if !reflect.DeepEqual(result.Args, tt.wantArgs) {
176+
t.Errorf("Args = %v, want %v", result.Args, tt.wantArgs)
177+
}
178+
})
179+
}
180+
}

gen/sqlx.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,28 @@ func (ctx *sqlxContext) genSqlxCode(w io.Writer) error {
404404
"getRepr": func(node ast.Node) string { return ctx.Doc.Repr(node) },
405405
"isQuery": func(op string) bool { return op == sqlxOpQuery },
406406
"isExec": func(op string) bool { return op == sqlxOpExec },
407+
"constBindSQL": func(header string) (string, error) {
408+
processed, err := readHeader(header, ctx.Pwd)
409+
if err != nil {
410+
return "", err
411+
}
412+
result, err := parseConstBindExpressions(processed)
413+
if err != nil {
414+
return "", err
415+
}
416+
return result.SQL, nil
417+
},
418+
"constBindArgs": func(header string) ([]string, error) {
419+
processed, err := readHeader(header, ctx.Pwd)
420+
if err != nil {
421+
return nil, err
422+
}
423+
result, err := parseConstBindExpressions(processed)
424+
if err != nil {
425+
return nil, err
426+
}
427+
return result.Args, nil
428+
},
407429
}).
408430
Parse(sqlxTemplate)
409431

gen/sqlx_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,21 @@ func TestBuildSqlx(t *testing.T) {
188188
return
189189
}
190190
})
191+
t.Run("success_constbind", func(t *testing.T) {
192+
builder, ok := newBuilder(t)
193+
if !ok {
194+
return
195+
}
196+
if err := runTest(genFile, builder); err != nil {
197+
t.Errorf("build: %s", err)
198+
return
199+
}
200+
builder = builder.WithFeats([]string{FeatureSqlxFuture, FeatureSqlxLog}).
201+
WithImports([]string{"C", "json encoding/json"}).
202+
WithFuncs([]string{"marshal: json.Marshal"})
203+
if err := runTest(genFile, builder); err != nil {
204+
t.Errorf("build: %s", err)
205+
return
206+
}
207+
})
191208
}

0 commit comments

Comments
 (0)