Skip to content

Commit ee38aa3

Browse files
feat: Introduce sqlc.optional for dynamic query generation
This commit introduces the `sqlc.optional` feature, allowing conditional inclusion of SQL query fragments at runtime. Key changes: 1. **Parser Enhancement**: The SQL parser now recognizes `sqlc.optional('ConditionKey', 'SQLFragment')` syntax within query files. This information is stored in the query's metadata. 2. **Code Generation**: - Go code generation logic has been updated to process these `OptionalBlocks`. - Generated Go functions now include new parameters (typed as `interface{}`) corresponding to each `ConditionKey`. - Templates (`stdlib/queryCode.tmpl`, `pgx/queryCode.tmpl`) were modified to dynamically build the SQL query string and its arguments at runtime. If an optional Go parameter is non-nil, its associated SQL fragment is included in the final query, and its value is added to the list of database arguments. 3. **Parameter Handling**: `$N` placeholders in all SQL fragments (base or optional) consistently refer to the Nth parameter in the generated Go function's signature. 4. **Documentation**: Added comprehensive documentation for `sqlc.optional` in `docs/reference/query-annotations.md`, covering syntax, behavior, parameter numbering, and examples. 5. **Examples**: A new runnable example has been added to `examples/dynamic_query/postgresql/` to demonstrate practical usage. 6. **Tests**: New end-to-end tests were added in `internal/endtoend/testdata/dynamic_query/` for both `stdlib` and `pgx` drivers, ensuring the correctness of the generated code.
1 parent a60f370 commit ee38aa3

File tree

29 files changed

+1301
-76
lines changed

29 files changed

+1301
-76
lines changed

docs/reference/query-annotations.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,180 @@ func (q *Queries) GetAuthor(ctx context.Context, id int64) (Author, error) {
113113
}
114114
```
115115

116+
## Conditional SQL with `sqlc.optional`
117+
118+
The `sqlc.optional` annotation allows for parts of a SQL query to be conditionally included at runtime. This is useful for building queries with optional filters or other dynamic components.
119+
120+
### Purpose
121+
122+
`sqlc.optional` provides a way to construct dynamic SQL queries where certain SQL fragments are only appended to the base query if a corresponding Go parameter is non-`nil`. This avoids the need for complex string manipulation or multiple similar queries for different filtering scenarios.
123+
124+
### Syntax
125+
126+
You include `sqlc.optional` calls directly in your SQL query comments, after the main query body. Each call specifies a key (which becomes part of the Go function parameter name) and the SQL fragment to include.
127+
128+
```sql
129+
-- name: GetItemsByOwner :many
130+
SELECT * FROM items
131+
WHERE owner_id = $1 -- Base condition for mandatory parameter
132+
sqlc.optional('NameFilter', 'AND name LIKE $2')
133+
sqlc.optional('ActiveOnly', 'AND is_active = $3');
134+
```
135+
136+
### Generated Function Signature
137+
138+
For each `sqlc.optional('Key', 'SQLFragment')` annotation, a new parameter is added to the generated Go function. The parameter name is derived from `Key` (converted to lowerCamelCase, e.g., `nameFilter`, `activeOnly`), and its type is `interface{}`.
139+
140+
Given the SQL example above, the generated Go function signature would be:
141+
142+
```go
143+
func (q *Queries) GetItemsByOwner(ctx context.Context, ownerID int64, nameFilter interface{}, activeOnly interface{}) ([]Item, error)
144+
```
145+
146+
Here, `ownerID int64` is the standard parameter corresponding to `$1`. `nameFilter interface{}` and `activeOnly interface{}` are the optional parameters generated due to `sqlc.optional`.
147+
148+
### Runtime Behavior
149+
150+
- The SQL fragment associated with an `sqlc.optional` directive is appended to the main query (with a preceding space) if the corresponding Go parameter in the generated function is **not `nil`**.
151+
- If the parameter is `nil`, the fragment is ignored.
152+
- The database driver receives the fully constructed SQL string and only the parameters that are active (standard parameters + non-`nil` optional parameters).
153+
154+
### Parameter Numbering
155+
156+
The `$N` placeholders in *any* SQL fragment (whether part of the base query or an `sqlc.optional` fragment) **must** correspond to the position of the argument in the generated Go function's parameter list.
157+
158+
- Standard (non-optional) parameters are numbered first, based on their order in the SQL query.
159+
- Optional parameters are numbered subsequently, based on the order of their `sqlc.optional` appearance in the SQL query.
160+
161+
**Example:**
162+
163+
For the query:
164+
```sql
165+
-- name: GetItemsByOwner :many
166+
SELECT * FROM items
167+
WHERE owner_id = $1 -- owner_id is the 1st parameter
168+
sqlc.optional('NameFilter', 'AND name LIKE $2') -- nameFilter is the 2nd parameter
169+
sqlc.optional('ActiveOnly', 'AND is_active = $3'); -- activeOnly is the 3rd parameter
170+
```
171+
172+
The generated Go function is:
173+
`func (q *Queries) GetItemsByOwner(ctx context.Context, ownerID int64, nameFilter interface{}, activeOnly interface{})`
174+
175+
- In the base query, `$1` refers to `ownerID`.
176+
- In the `NameFilter` fragment, `$2` refers to `nameFilter`.
177+
- In the `ActiveOnly` fragment, `$3` refers to `activeOnly`.
178+
179+
If `nameFilter` is `nil` and `activeOnly` is provided, the final SQL sent to the driver might look like:
180+
`SELECT * FROM items WHERE owner_id = $1 AND is_active = $2`
181+
And the parameters passed to the driver would be `ownerID` and the value of `activeOnly`. The database driver sees a query with parameters re-numbered sequentially from `$1`. sqlc handles this re-numbering automatically when constructing the query for the driver.
182+
183+
### Complete Example
184+
185+
**SQL (`query.sql`):**
186+
```sql
187+
-- name: ListUsers :many
188+
SELECT id, name, status FROM users
189+
WHERE 1=1 -- Base condition (can be any valid SQL expression)
190+
sqlc.optional('NameParam', 'AND name LIKE $1')
191+
sqlc.optional('StatusParam', 'AND status = $2');
192+
```
193+
*(For this specific example, if `NameParam` is active, it's `$1`. If `StatusParam` is active, it's `$2`. If both are active, `NameParam` is `$1` and `StatusParam` is `$2` in their respective fragments, but they become `$1` and `$2` overall if no mandatory params precede them. The parameter numbering in fragments refers to their final position in the argument list passed to the database driver, which sqlc constructs based on active parameters.)*
194+
195+
**Correction to the above parenthetical note, aligning with the "Parameter Numbering" section:**
196+
The `$N` in the SQL fragments refers to the Go function signature's parameter order.
197+
- `NameParam` (if not nil) corresponds to `$1`.
198+
- `StatusParam` (if not nil) corresponds to `$2`.
199+
200+
If `NameParam` is `John%` and `StatusParam` is `active`, the effective SQL is:
201+
`SELECT id, name, status FROM users WHERE 1=1 AND name LIKE $1 AND status = $2`
202+
And the parameters passed to the driver are `John%` and `active`.
203+
204+
If `NameParam` is `nil` and `StatusParam` is `active`, the effective SQL is:
205+
`SELECT id, name, status FROM users WHERE 1=1 AND status = $1`
206+
And the parameter passed to the driver is `active`. sqlc handles mapping the Go parameters to the correct positional placeholders for the final SQL.
207+
208+
209+
**Generated Go (`query.sql.go`):**
210+
```go
211+
func (q *Queries) ListUsers(ctx context.Context, nameParam interface{}, statusParam interface{}) ([]User, error) {
212+
// ... implementation using strings.Builder ...
213+
}
214+
```
215+
216+
**Example Usage (Go):**
217+
```go
218+
package main
219+
220+
import (
221+
"context"
222+
"database/sql"
223+
"fmt"
224+
"log"
225+
226+
// assume models and queries are in package "db"
227+
"yourmodule/db" // Adjust to your actual module path
228+
)
229+
230+
func main() {
231+
ctx := context.Background()
232+
// Assume dbConn is an initialized *sql.DB
233+
var dbConn *sql.DB
234+
// dbConn, err := sql.Open("driver-name", "connection-string")
235+
// if err != nil {
236+
// log.Fatal(err)
237+
// }
238+
// defer dbConn.Close()
239+
240+
queries := db.New(dbConn)
241+
242+
// Example 1: Get all users (both optional parameters are nil)
243+
fmt.Println("Fetching all users...")
244+
allUsers, err := queries.ListUsers(ctx, nil, nil)
245+
if err != nil {
246+
log.Fatalf("Failed to list all users: %v", err)
247+
}
248+
for _, user := range allUsers {
249+
fmt.Printf("User: ID=%d, Name=%s, Status=%s\n", user.ID, user.Name, user.Status)
250+
}
251+
252+
fmt.Println("\nFetching users with name starting with 'J':")
253+
// Example 2: Get users with name starting with "J"
254+
nameFilter := "J%"
255+
jUsers, err := queries.ListUsers(ctx, nameFilter, nil)
256+
if err != nil {
257+
log.Fatalf("Failed to list J-users: %v", err)
258+
}
259+
for _, user := range jUsers {
260+
fmt.Printf("User: ID=%d, Name=%s, Status=%s\n", user.ID, user.Name, user.Status)
261+
}
262+
263+
fmt.Println("\nFetching 'active' users:")
264+
// Example 3: Get 'active' users
265+
statusFilter := "active"
266+
activeUsers, err := queries.ListUsers(ctx, nil, statusFilter)
267+
if err != nil {
268+
log.Fatalf("Failed to list active users: %v", err)
269+
}
270+
for _, user := range activeUsers {
271+
fmt.Printf("User: ID=%d, Name=%s, Status=%s\n", user.ID, user.Name, user.Status)
272+
}
273+
274+
fmt.Println("\nFetching 'inactive' users with name 'Jane Doe':")
275+
// Example 4: Get 'inactive' users with name 'Jane Doe'
276+
nameFilterSpecific := "Jane Doe"
277+
statusFilterSpecific := "inactive"
278+
janeUsers, err := queries.ListUsers(ctx, nameFilterSpecific, statusFilterSpecific)
279+
if err != nil {
280+
log.Fatalf("Failed to list specific Jane users: %v", err)
281+
}
282+
for _, user := range janeUsers {
283+
fmt.Printf("User: ID=%d, Name=%s, Status=%s\n", user.ID, user.Name, user.Status)
284+
}
285+
}
286+
```
287+
288+
This feature provides a powerful way to reduce boilerplate and manage complex queries with multiple optional conditions directly within your SQL files.
289+
116290
## `:batchexec`
117291

118292
__NOTE: This command only works with PostgreSQL using the `pgx/v4` and `pgx/v5` drivers and outputting Go code.__
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package postgresql
2+
3+
import (
4+
"context"
5+
6+
"github.com/jackc/pgx/v5"
7+
"github.com/jackc/pgx/v5/pgconn"
8+
)
9+
10+
type DBTX interface {
11+
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
12+
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
13+
QueryRow(context.Context, string, ...interface{}) pgx.Row
14+
Begin(context.Context) (pgx.Tx, error)
15+
}
16+
17+
func New(db DBTX) *Queries {
18+
return &Queries{db: db}
19+
}
20+
21+
type Queries struct {
22+
db DBTX
23+
}
24+
25+
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
26+
return &Queries{
27+
db: tx,
28+
}
29+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package postgresql
2+
3+
import (
4+
"database/sql"
5+
"time"
6+
)
7+
8+
type Product struct {
9+
ID int32 `json:"id"`
10+
Name string `json:"name"`
11+
Category string `json:"category"`
12+
Price int32 `json:"price"`
13+
IsAvailable sql.NullBool `json:"is_available"`
14+
CreatedAt sql.NullTime `json:"created_at"`
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package postgresql
2+
3+
import (
4+
"context"
5+
)
6+
7+
type Querier interface {
8+
GetProducts(ctx context.Context, category interface{}, minPrice interface{}, isAvailable interface{}) ([]Product, error)
9+
AddProduct(ctx context.Context, arg AddProductParams) (Product, error)
10+
}
11+
12+
var _ Querier = (*Queries)(nil)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- name: GetProducts :many
2+
SELECT * FROM products
3+
WHERE 1=1
4+
sqlc.optional('Category', 'AND category = $1')
5+
sqlc.optional('MinPrice', 'AND price >= $2')
6+
sqlc.optional('IsAvailable', 'AND is_available = $3');
7+
8+
-- name: AddProduct :one
9+
INSERT INTO products (name, category, price, is_available)
10+
VALUES ($1, $2, $3, $4)
11+
RETURNING *;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package postgresql
2+
3+
import (
4+
"context"
5+
"fmt" // Ensure fmt is imported
6+
"strings"
7+
)
8+
9+
const getProducts = `-- name: GetProducts :many
10+
SELECT id, name, category, price, is_available, created_at FROM products
11+
WHERE 1=1
12+
`
13+
14+
// GetProductsParams is a placeholder as the function takes optional params directly.
15+
// It's not used by the generated GetProducts function itself but might be useful
16+
// for users if they wanted to wrap the call.
17+
type GetProductsParams struct {
18+
Category interface{} `json:"category"`
19+
MinPrice interface{} `json:"min_price"`
20+
IsAvailable interface{} `json:"is_available"`
21+
}
22+
23+
func (q *Queries) GetProducts(ctx context.Context, category interface{}, minPrice interface{}, isAvailable interface{}) ([]Product, error) {
24+
var sqlBuilder strings.Builder
25+
sqlBuilder.WriteString(getProducts) // Base query
26+
27+
var queryParams []interface{}
28+
29+
// Optional 'Category'
30+
if category != nil {
31+
sqlBuilder.WriteString(" AND category = $")
32+
queryParams = append(queryParams, category)
33+
sqlBuilder.WriteString(fmt.Sprintf("%d", len(queryParams)))
34+
}
35+
36+
// Optional 'MinPrice'
37+
if minPrice != nil {
38+
sqlBuilder.WriteString(" AND price >= $")
39+
queryParams = append(queryParams, minPrice)
40+
sqlBuilder.WriteString(fmt.Sprintf("%d", len(queryParams)))
41+
}
42+
43+
// Optional 'IsAvailable'
44+
if isAvailable != nil {
45+
sqlBuilder.WriteString(" AND is_available = $")
46+
queryParams = append(queryParams, isAvailable)
47+
sqlBuilder.WriteString(fmt.Sprintf("%d", len(queryParams)))
48+
}
49+
50+
rows, err := q.db.Query(ctx, sqlBuilder.String(), queryParams...)
51+
if err != nil {
52+
return nil, err
53+
}
54+
defer rows.Close()
55+
var items []Product
56+
for rows.Next() {
57+
var i Product
58+
if err := rows.Scan(
59+
&i.ID,
60+
&i.Name,
61+
&i.Category,
62+
&i.Price,
63+
&i.IsAvailable,
64+
&i.CreatedAt,
65+
); err != nil {
66+
return nil, err
67+
}
68+
items = append(items, i)
69+
}
70+
if err := rows.Err(); err != nil {
71+
return nil, err
72+
}
73+
return items, nil
74+
}
75+
76+
const addProduct = `-- name: AddProduct :one
77+
INSERT INTO products (name, category, price, is_available)
78+
VALUES ($1, $2, $3, $4)
79+
RETURNING id, name, category, price, is_available, created_at
80+
`
81+
82+
type AddProductParams struct {
83+
Name string `json:"name"`
84+
Category string `json:"category"`
85+
Price int32 `json:"price"`
86+
IsAvailable bool `json:"is_available"`
87+
}
88+
89+
func (q *Queries) AddProduct(ctx context.Context, arg AddProductParams) (Product, error) {
90+
row := q.db.QueryRow(ctx, addProduct,
91+
arg.Name,
92+
arg.Category,
93+
arg.Price,
94+
arg.IsAvailable,
95+
)
96+
var i Product
97+
err := row.Scan(
98+
&i.ID,
99+
&i.Name,
100+
&i.Category,
101+
&i.Price,
102+
&i.IsAvailable,
103+
&i.CreatedAt,
104+
)
105+
return i, err
106+
}

0 commit comments

Comments
 (0)