Skip to content

Commit 3af25ee

Browse files
authored
feat(builder)!: new API and docs (#23)
1 parent 3f928a0 commit 3af25ee

File tree

4 files changed

+95
-101
lines changed

4 files changed

+95
-101
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Convenience helpers for working with SQL queries.
1515

1616
## 📦 Install
1717

18-
Go 1.23+
18+
Go 1.24+
1919

2020
```shell
2121
go get go-simpler.org/queries

builder.go

Lines changed: 58 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,101 +12,106 @@ import (
1212
// The zero value is ready to use.
1313
// Do not copy a non-zero Builder.
1414
type Builder struct {
15+
// TODO: prealloc?
1516
query strings.Builder
1617
args []any
1718
counter int
1819
placeholder rune
1920
}
2021

2122
// Appendf formats according to the given format and appends the result to the query.
22-
// It works like [fmt.Appendf], i.e. all rules from the [fmt] package are applied.
23-
// In addition, Appendf supports %?, %$, and %@ verbs, which are automatically expanded to the query placeholders ?, $N, and @pN,
24-
// where N is the auto-incrementing counter.
25-
// The corresponding arguments can then be accessed with the [Builder.Args] method.
23+
// It works like [fmt.Appendf], meaning all the rules from the [fmt] package are applied.
24+
// In addition, Appendf supports special verbs that automatically expand to database placeholders.
2625
//
27-
// IMPORTANT: to avoid SQL injections, make sure to pass arguments from user input with placeholder verbs.
26+
// ---------------------------------------------
27+
// | Database | Verb | Placeholder |
28+
// |----------------------|------|-------------|
29+
// | MySQL, SQLite | %? | ? |
30+
// | PostgreSQL | %$ | $N |
31+
// | Microsoft SQL Server | %@ | @pN |
32+
// ---------------------------------------------
2833
//
29-
// Placeholder verbs map to the following database placeholders:
30-
// - MySQL, SQLite: %? -> ?
31-
// - PostgreSQL: %$ -> $N
32-
// - MSSQL: %@ -> @pN
34+
// Here, N is an auto-incrementing counter.
35+
// For example, "%$, %$, %$" expands to "$1, $2, $3".
3336
//
34-
// TODO: document slice arguments usage.
35-
func (b *Builder) Appendf(format string, args ...any) {
36-
a := make([]any, len(args))
37-
for i, arg := range args {
38-
a[i] = argument{value: arg, builder: b}
37+
// If a special verb includes the "+" flag, it automatically expands to multiple placeholders.
38+
// For example, given the verb "%+?" and the argument []int{1, 2, 3},
39+
// Appendf writes "?, ?, ?" to the query and appends 1, 2, and 3 to the arguments.
40+
// You may want to use this flag to build "WHERE IN (...)" clauses.
41+
//
42+
// Make sure to always pass arguments from user input with placeholder verbs to avoid SQL injections.
43+
func (b *Builder) Appendf(format string, a ...any) {
44+
fs := make([]any, len(a))
45+
for i := range a {
46+
fs[i] = formatter{arg: a[i], builder: b}
3947
}
40-
fmt.Fprintf(&b.query, format, a...)
48+
fmt.Fprintf(&b.query, format, fs...)
4149
}
4250

43-
// Query returns the query string.
44-
func (b *Builder) Query() string { return b.query.String() }
51+
// Build returns the query and its arguments.
52+
func (b *Builder) Build() (query string, args []any) {
53+
return b.query.String(), b.args
54+
}
4555

46-
// Args returns the query arguments.
47-
func (b *Builder) Args() []any { return b.args }
56+
// Build is a shorthand for a new [Builder] + [Builder.Appendf] + [Builder.Build].
57+
func Build(format string, a ...any) (query string, args []any) {
58+
var b Builder
59+
b.Appendf(format, a...)
60+
return b.Build()
61+
}
4862

49-
type argument struct {
50-
value any
63+
type formatter struct {
64+
arg any
5165
builder *Builder
5266
}
5367

5468
// Format implements [fmt.Formatter].
55-
func (a argument) Format(s fmt.State, verb rune) {
69+
func (f formatter) Format(s fmt.State, verb rune) {
5670
switch verb {
5771
case '?', '$', '@':
58-
if a.builder.placeholder == 0 {
59-
a.builder.placeholder = verb
72+
if f.builder.placeholder == 0 {
73+
f.builder.placeholder = verb
6074
}
61-
if a.builder.placeholder != verb {
75+
if f.builder.placeholder != verb {
6276
panic("unexpected placeholder")
6377
}
78+
if s.Flag('+') {
79+
appendAll(s, f.builder, verb, f.arg)
80+
} else {
81+
appendOne(s, f.builder, verb, f.arg)
82+
}
6483
default:
6584
format := fmt.FormatString(s, verb)
66-
fmt.Fprintf(s, format, a.value)
67-
return
68-
}
69-
70-
if s.Flag('+') {
71-
a.writeSlice(s, verb)
72-
} else {
73-
a.writePlaceholder(s, verb)
74-
a.builder.args = append(a.builder.args, a.value)
85+
fmt.Fprintf(s, format, f.arg)
7586
}
7687
}
7788

78-
func (a argument) writePlaceholder(w io.Writer, verb rune) {
89+
func appendOne(w io.Writer, b *Builder, verb rune, arg any) {
7990
switch verb {
80-
case '?': // MySQL, SQLite
91+
case '?':
8192
fmt.Fprint(w, "?")
82-
case '$': // PostgreSQL
83-
a.builder.counter++
84-
fmt.Fprintf(w, "$%d", a.builder.counter)
85-
case '@': // MSSQL
86-
a.builder.counter++
87-
fmt.Fprintf(w, "@p%d", a.builder.counter)
93+
case '$':
94+
b.counter++
95+
fmt.Fprintf(w, "$%d", b.counter)
96+
case '@':
97+
b.counter++
98+
fmt.Fprintf(w, "@p%d", b.counter)
8899
}
100+
b.args = append(b.args, arg)
89101
}
90102

91-
func (a argument) writeSlice(w io.Writer, verb rune) {
92-
slice := reflect.ValueOf(a.value)
103+
func appendAll(w io.Writer, b *Builder, verb rune, arg any) {
104+
slice := reflect.ValueOf(arg)
93105
if slice.Kind() != reflect.Slice {
94106
panic("non-slice argument")
95107
}
96-
97108
if slice.Len() == 0 {
98-
// TODO: revisit.
99-
// "WHERE IN (NULL)" will always result in an empty result set,
100-
// which may be undesirable in some situations.
101-
fmt.Fprint(w, "NULL")
102-
return
109+
panic("zero-length slice argument")
103110
}
104-
105111
for i := range slice.Len() {
106112
if i > 0 {
107113
fmt.Fprint(w, ", ")
108114
}
109-
a.writePlaceholder(w, verb)
110-
a.builder.args = append(a.builder.args, slice.Index(i).Interface())
115+
appendOne(w, b, verb, slice.Index(i).Interface())
111116
}
112117
}

builder_test.go

Lines changed: 34 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ func TestBuilder(t *testing.T) {
1717
qb.Appendf(" AND bar = %$", "test")
1818
qb.Appendf(" AND baz = %$", false)
1919

20-
assert.Equal[E](t, qb.Query(), "SELECT * FROM tbl WHERE 1=1 AND foo = $1 AND bar = $2 AND baz = $3")
21-
assert.Equal[E](t, qb.Args(), []any{42, "test", false})
20+
query, args := qb.Build()
21+
assert.Equal[E](t, query, "SELECT * FROM tbl WHERE 1=1 AND foo = $1 AND bar = $2 AND baz = $3")
22+
assert.Equal[E](t, args, []any{42, "test", false})
2223
}
2324

2425
func TestBuilder_dialects(t *testing.T) {
@@ -42,72 +43,61 @@ func TestBuilder_dialects(t *testing.T) {
4243

4344
for name, test := range tests {
4445
t.Run(name, func(t *testing.T) {
45-
var qb queries.Builder
46-
qb.Appendf(test.format, 1, 2, 3)
47-
assert.Equal[E](t, qb.Query(), test.query)
48-
assert.Equal[E](t, qb.Args(), []any{1, 2, 3})
46+
query, args := queries.Build(test.format, 1, 2, 3)
47+
assert.Equal[E](t, query, test.query)
48+
assert.Equal[E](t, args, []any{1, 2, 3})
4949
})
5050
}
5151
}
5252

5353
func TestBuilder_sliceArgument(t *testing.T) {
54-
t.Run("ok", func(t *testing.T) {
55-
var qb queries.Builder
56-
qb.Appendf("SELECT * FROM tbl WHERE foo IN (%+$)", []int{1, 2, 3})
57-
assert.Equal[E](t, qb.Query(), "SELECT * FROM tbl WHERE foo IN ($1, $2, $3)")
58-
assert.Equal[E](t, qb.Args(), []any{1, 2, 3})
59-
})
60-
61-
t.Run("empty", func(t *testing.T) {
62-
var qb queries.Builder
63-
qb.Appendf("SELECT * FROM tbl WHERE foo IN (%+$)", []int{})
64-
assert.Equal[E](t, qb.Query(), "SELECT * FROM tbl WHERE foo IN (NULL)")
65-
assert.Equal[E](t, len(qb.Args()), 0)
66-
})
54+
query, args := queries.Build("SELECT * FROM tbl WHERE foo IN (%+$)", []int{1, 2, 3})
55+
assert.Equal[E](t, query, "SELECT * FROM tbl WHERE foo IN ($1, $2, $3)")
56+
assert.Equal[E](t, args, []any{1, 2, 3})
6757
}
6858

6959
func TestBuilder_badQuery(t *testing.T) {
7060
tests := map[string]struct {
71-
appendf func(*queries.Builder)
72-
query string
61+
format string
62+
args []any
63+
query string
7364
}{
7465
"wrong verb": {
75-
appendf: func(qb *queries.Builder) {
76-
qb.Appendf("SELECT %d FROM tbl", "foo")
77-
},
78-
query: "SELECT %!d(string=foo) FROM tbl",
66+
format: "SELECT %d FROM tbl",
67+
args: []any{"foo"},
68+
query: "SELECT %!d(string=foo) FROM tbl",
7969
},
8070
"too few arguments": {
81-
appendf: func(qb *queries.Builder) {
82-
qb.Appendf("SELECT %s FROM tbl")
83-
},
84-
query: "SELECT %!s(MISSING) FROM tbl",
71+
format: "SELECT %s FROM tbl",
72+
args: []any{},
73+
query: "SELECT %!s(MISSING) FROM tbl",
8574
},
8675
"too many arguments": {
87-
appendf: func(qb *queries.Builder) {
88-
qb.Appendf("SELECT %s FROM tbl", "foo", "bar")
89-
},
90-
query: "SELECT foo FROM tbl%!(EXTRA queries.argument=bar)",
76+
format: "SELECT %s FROM tbl",
77+
args: []any{"foo", "bar"},
78+
query: "SELECT foo FROM tbl%!(EXTRA queries.formatter=bar)",
9179
},
9280
"unexpected placeholder": {
93-
appendf: func(qb *queries.Builder) {
94-
qb.Appendf("SELECT * FROM tbl WHERE foo = %? AND bar = %$", 1, 2)
95-
},
96-
query: "SELECT * FROM tbl WHERE foo = ? AND bar = %!$(PANIC=Format method: unexpected placeholder)",
81+
format: "SELECT * FROM tbl WHERE foo = %? AND bar = %$",
82+
args: []any{1, 2},
83+
query: "SELECT * FROM tbl WHERE foo = ? AND bar = %!$(PANIC=Format method: unexpected placeholder)",
9784
},
9885
"non-slice argument": {
99-
appendf: func(qb *queries.Builder) {
100-
qb.Appendf("SELECT * FROM tbl WHERE foo IN (%+$)", 1)
101-
},
102-
query: "SELECT * FROM tbl WHERE foo IN (%!$(PANIC=Format method: non-slice argument))",
86+
format: "SELECT * FROM tbl WHERE foo IN (%+$)",
87+
args: []any{1},
88+
query: "SELECT * FROM tbl WHERE foo IN (%!$(PANIC=Format method: non-slice argument))",
89+
},
90+
"zero-length slice argument": {
91+
format: "SELECT * FROM tbl WHERE foo IN (%+$)",
92+
args: []any{[]int{}},
93+
query: "SELECT * FROM tbl WHERE foo IN (%!$(PANIC=Format method: zero-length slice argument))",
10394
},
10495
}
10596

10697
for name, test := range tests {
10798
t.Run(name, func(t *testing.T) {
108-
var qb queries.Builder
109-
test.appendf(&qb)
110-
assert.Equal[E](t, qb.Query(), test.query)
99+
query, _ := queries.Build(test.format, test.args...)
100+
assert.Equal[E](t, query, test.query)
111101
})
112102
}
113103
}

tests/integration_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
183183
}
184184

185185
for _, m := range migrations {
186-
var qb queries.Builder
187-
qb.Appendf(m.query, m.args...)
188-
if _, err := db.ExecContext(ctx, qb.Query(), qb.Args()...); err != nil {
186+
query, args := queries.Build(m.query, m.args...)
187+
if _, err := db.ExecContext(ctx, query, args...); err != nil {
189188
return err
190189
}
191190
}

0 commit comments

Comments
 (0)