Middleware

What Middleware Is

In Juice, middleware intercepts the SQL execution chain. It is the right place for logging, timeout control, tracing, metrics, datasource routing, and other cross-cutting behavior.

The current core interfaces are:

type Handler[T any] func(ctx context.Context, query string, args ...any) (T, error)

type QueryHandler = Handler[sql.Rows]
type ExecHandler = Handler[sql.Result]

type Middleware interface {
    QueryContext(ctx *StatementContext, next QueryHandler) QueryHandler
    ExecContext(ctx *StatementContext, next ExecHandler) ExecHandler
}

StatementContext carries the execution metadata available to middleware:

type StatementContext struct {
    // Fields are unexported. Use methods to access them.
}

func (m *StatementContext) Engine() *Engine
func (m *StatementContext) Statement() Statement
func (m *StatementContext) Context() context.Context
func (m *StatementContext) Param() eval.Param
func (m *StatementContext) Session() session.Session
func (m *StatementContext) WithSession(session session.Session)

QueryContext intercepts query statements. ExecContext intercepts insert, update, delete, and write-style raw SQL operations.

Registration and Order

Register middleware with engine.Use:

engine.Use(&TraceMiddleware{})
engine.Use(&MetricsMiddleware{})

Middleware is composed in registration order, but the last registered middleware runs first. If you register A, then B, then C, runtime flow is:

C before -> B before -> A before -> database -> A after -> B after -> C after

juice.New installs the core generated-key middleware. juice.Default builds on New and additionally installs TimeoutMiddleware and DebugMiddleware.

DebugMiddleware

DebugMiddleware logs SQL, bound arguments, and elapsed time.

It is installed automatically when you initialize the engine with juice.Default:

engine, err := juice.Default(cfg)

To disable SQL logging globally:

<settings>
    <setting name="debug" value="false"/>
</settings>

To disable SQL logging for a single statement:

<select id="GetUser" debug="false">
    select * from user where id = #{id}
</select>

Priority:

  1. Statement-level debug="false" disables logging for that statement.

  2. Global <setting name="debug" value="false"/> disables logging globally.

  3. If neither is set to false, debug logging is enabled.

TimeoutMiddleware

TimeoutMiddleware reads the statement timeout attribute and wraps execution with context.WithTimeout. The unit is milliseconds.

<select id="GetUser" timeout="1000">
    select * from user where id = #{id}
</select>

Attention

TimeoutMiddleware applies a Go context timeout. Whether a running database statement is interrupted promptly depends on the concrete database driver and its context.Context support.

TxSensitiveDataSourceSwitchMiddleware

TxSensitiveDataSourceSwitchMiddleware routes query statements to different datasources. It is commonly used for read-write splitting.

Enable it with:

engine.Use(&juice.TxSensitiveDataSourceSwitchMiddleware{})

Datasource selection priority:

  1. The dataSource attribute on a select statement.

  2. The global selectDataSource setting.

  3. No switch when neither is configured.

Special values:

  • ?: randomly choose from all registered datasources.

  • ?!: randomly choose from datasources other than the current default datasource. If none is available, it falls back to the current datasource.

  • Any other string: use that environment id directly, for example slave1.

Example:

<settings>
    <setting name="selectDataSource" value="?!"/>
</settings>

<select id="GetUser" dataSource="slave1">
    select * from user where id = #{id}
</select>

The middleware is transaction-aware. If the current execution is already inside a transaction, it does not switch datasources and continues to use the current transaction session.

Custom Middleware

Custom middleware only needs to implement the Middleware interface.

Simple tracing example:

type TraceMiddleware struct{}

func (m TraceMiddleware) QueryContext(sc *juice.StatementContext, next juice.QueryHandler) juice.QueryHandler {
    stmt := sc.Statement()
    return func(ctx context.Context, query string, args ...any) (juiceSql.Rows, error) {
        trace.Log(ctx, "statement", stmt.Name())
        trace.Log(ctx, "query", query)
        return next(ctx, query, args...)
    }
}

func (m TraceMiddleware) ExecContext(sc *juice.StatementContext, next juice.ExecHandler) juice.ExecHandler {
    stmt := sc.Statement()
    return func(ctx context.Context, query string, args ...any) (juiceSql.Result, error) {
        trace.Log(ctx, "statement", stmt.Name())
        trace.Log(ctx, "exec", query)
        return next(ctx, query, args...)
    }
}

In this example, juiceSql is an import alias for github.com/go-juicedev/juice/sql.

Register it with:

engine.Use(TraceMiddleware{})

Changing the Execution Session

Middleware that needs to route the remaining execution chain to another session can call StatementContext.WithSession. The built-in TxSensitiveDataSourceSwitchMiddleware uses this mechanism to switch datasources for non-transactional queries.

Most application middleware does not need to change the session. Logging, metrics, tracing, and audit middleware can usually just wrap next.