Result Mapping
Result mapping is a core feature of Juice. It converts database query results into structured Go values.
The current version mainly uses column struct tags, generic binding helpers, and the sql.RowScanner extension point.
Attention
XML still keeps resultMap-related structural constraints, but the current XML statement ResultMap implementation returns
sql.ErrResultMapNotSet and falls back to the default mapping behavior at execution time. For complex mappings, use SQL aliases,
column struct tags, or implement sql.RowScanner on your target type.
Native sql.Rows Support
Juice is fully compatible with *database/sql.Rows through its own small Rows interface:
type Rows interface {
Next() bool
Scan(dest ...any) error
Close() error
Err() error
Columns() ([]string, error)
}
In the function signatures below, sql.Rows refers to github.com/go-juicedev/juice/sql.Rows. *database/sql.Rows implements this interface.
Basic example:
rows, err := engine.Object("main.SelectUser").QueryContext(context.TODO(), nil)
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var user User
if err := rows.Scan(&user.Id, &user.Name, &user.Age); err != nil {
panic(err)
}
fmt.Println(user)
}
if err = rows.Err(); err != nil {
panic(err)
}
About Object
Object supports multiple kinds of identifiers:
String
engine.Object("main.SelectUser")
Function
Use the function’s location in code as the statement ID.
Note
Object ID generation rules:
package.FunctionNamefor plain functionspackage.TypeName.MethodNamefor struct methodsKeep interface methods and struct methods distinct
Generic Result Mapping
Juice provides strong support for generics, making result mapping more type-safe.
Mapping Scenarios
Single column, single row
count, err := juice.NewGenericManager[int](engine). Object("CountUsers").QueryContext(context.TODO(), nil)
Multiple columns, single row
type User struct { ID int64 `column:"id"` Name string `column:"name"` } user, err := juice.NewGenericManager[User](engine). Object("GetUser").QueryContext(context.TODO(), nil)
Single column, multiple rows
ids, err := juice.NewGenericManager[[]int64](engine). Object("GetUserIDs").QueryContext(context.TODO(), nil)
Multiple columns, multiple rows
users, err := juice.NewGenericManager[[]User](engine). Object("GetUsers").QueryContext(context.TODO(), nil)
Attention
Structs should use the
columntag to map database columns.Multi-row results should be received with slice types.
Mapping directly into
mapis not supported by design.
Custom Result Mapping
Juice provides four main result-mapping helpers: Bind, List, List2, and Iter.
Bind
Bind is the most flexible option and can map many result shapes:
func Bind[T any](rows sql.Rows) (result T, err error)
Characteristics:
supports arbitrary mapping targets such as structs, slices, and scalar types
offers the most flexibility
can handle both single-row and multi-row data
Example:
type User struct {
ID int `column:"id"`
Name string `column:"name"`
}
rows, _ := db.Query("SELECT id, name FROM users")
defer rows.Close()
users, err := juice.Bind[[]User](rows)
user, err := juice.Bind[User](rows)
List
List is specialized for mapping into slice types:
func List[T any](rows sql.Rows) (result []T, err error)
Characteristics:
always returns
[]Tperforms better than
Bindfor slice-oriented use casesreturns an empty slice rather than
nilfor empty resultsincludes optimizations for non-pointer element types
Example:
rows, _ := db.Query("SELECT id, name FROM users")
defer rows.Close()
users, err := juice.List[User](rows)
List2
List2 is a List variant that returns a slice of pointers:
func List2[T any](rows sql.Rows) ([]*T, error)
It is mainly intended for pointer-oriented generic result sets.
Characteristics:
returns
[]*Tuseful when elements need to be modified later
useful for large structs
avoids value-copy overhead
Example:
rows, _ := db.Query("SELECT id, name FROM users")
defer rows.Close()
users, err := juice.List2[User](rows)
// users is []*User
Iter
Iter converts a result set into an iterator so that you do not need to load everything into memory at once.
rows, _ := db.Query("SELECT id, name FROM users")
defer rows.Close()
iterator, err := juice.Iter[User](rows)
if err != nil {
return err
}
for user, err := range iterator {
if err != nil {
return err
}
fmt.Println(user)
}
Iter returns an iter.Seq2[T, error]. Keep the rows open while iterating and close them when you are done.
RowScanner
When the default column tag mapping is not enough, the target type can implement sql.RowScanner.
The binding logic will then delegate scanning to your type.
import "github.com/go-juicedev/juice/sql"
type User struct {
ID int64
Name string
}
func (u *User) ScanRows(rows sql.Rows) error {
return rows.Scan(&u.ID, &u.Name)
}
This is useful for custom column conversion, legacy schemas, JSON decoding, and other cases that are awkward to express with default reflection-based mapping.
Context Shortcuts
When a context carries a manager via juice.ContextWithManager, you can use shortcut helpers instead of manually creating an executor:
ctx := juice.ContextWithManager(context.Background(), engine)
user, err := juice.QueryContext[User](ctx, "GetUser", juice.H{"id": 1})
users, err := juice.QueryListContext[User](ctx, "ListUsers", nil)
userPtrs, err := juice.QueryList2Context[User](ctx, "ListUsers", nil)
iter, err := juice.QueryIterContext[User](ctx, "ListUsers", nil)
The iterator shortcut closes the underlying rows when iteration finishes or stops. The returned iterator must still be consumed; otherwise rows cannot be closed by the wrapper.
Selection Guide
Use
Bindwhen:you need maximum flexibility
you are not sure about the final shape yet
you need to handle single-row results
Use
Listwhen:you know the result is a slice
you want better performance for value slices
you prefer empty slices over
nil
Use
List2when:you need to modify elements after mapping
you are dealing with large structs
you want to avoid value copying
Use
Iterwhen:you need to process large result sets incrementally
Note
Performance notes:
Listis specially optimized for non-pointer element types.List2adds another choice for pointer-oriented code generation and usage.Bindis the most flexible, but not always the fastest.Iteris the best fit for large streaming-style workloads, but it holds a connection while you are iterating.
Auto-Increment Primary Keys
Juice supports automatically retrieving generated primary key values:
<insert id="CreateUser" useGeneratedKeys="true" keyProperty="ID">
INSERT INTO users (name, age) VALUES (#{name}, #{age})
</insert>
Requirements:
The database driver must support
LastInsertId.The parameter must be a struct pointer.
useGeneratedKeys="true"must be enabled.You must specify
keyPropertyor useautoincr:"true".The key field type must support integer assignment.
Batch Insert Optimization
Juice supports efficient batch inserts:
<insert id="BatchInsertUsers" batchSize="100">
INSERT INTO users (name, age) VALUES
<foreach collection="users" item="user" open="(" separator="," close=")">
(#{user.name}, #{user.age})
</foreach>
</insert>
For batch inserts, the parameter type must be a slice, an array, or a map with exactly one key whose value is a slice or array.
Optimization features:
Smart batching
automatically splits large datasets into batches
batch size is configurable via
batchSizesingle execution is the default behavior
Prepared-statement optimization
prepared statements are reused
at most two prepared statements are generated
database pressure is reduced effectively
Performance suggestions
recommended batch size: 50-1000
tune according to data volume and database capacity
avoid oversized batches that overload the database
Tip
Batch insert best practices:
Set batch sizes carefully.
Monitor database performance.
Consider transaction boundaries.
Handle errors thoroughly.