Skip to content

Dynamic SQL

<if>, <choose>, <where>, <set>, and <trim> all work out of the box, and none of them exist as a runtime AST. The generator flattens the XML tags into boolean variables and guarded string appends. At runtime, each test condition is evaluated exactly once.

What it compiles to

<select id="search" resultType="com.example.app.User">
  SELECT id, name, email, created_at FROM users
  <where>
    <if test="name != null">AND name LIKE #{name}</if>
    <if test="minAge != null">AND age &gt;= #{minAge}</if>
  </where>
  ORDER BY id
</select>
@Override
public List<User> search(UserQuery q) {
    boolean c0 = q.getName() != null;          // (1)!
    boolean c1 = q.getMinAge() != null;
    StringBuilder sb = new StringBuilder(96);  // (2)!
    sb.append("SELECT id, name, email, created_at FROM users");
    if (c0 | c1) {
        sb.append(" WHERE");                   // (3)!
    }
    if (c0) {
        sb.append(" name LIKE ?");
    }
    if (c1) {
        sb.append(c0 ? " AND age >= ?" : " age >= ?");   // (4)!
    }
    sb.append(" ORDER BY id");
    String sql = sb.toString();
    Connection c = s.conn();
    try (PreparedStatement ps = c.prepareStatement(sql)) {
        int i = 1;
        if (c0) {
            ps.setString(i++, q.getName());    // (5)!
        }
        if (c1) {
            JdbcCodec.setInt(ps, i++, q.getMinAge());
        }
        // ... read rows
    }
}
  1. Each test condition is evaluated once into a local boolean. The same boolean drives both SQL assembly and parameter binding, so they can never disagree.
  2. StringBuilder initial capacity is computed at build time from the maximum possible query length.
  3. <where> turns into a simple conditional append rather than a runtime string scan for leading AND/OR.
  4. The leading AND/OR strip rule is constant-folded: the first active branch omits AND. It's a simple ternary expression over precomputed booleans.
  5. Parameter binding walks the exact same conditions in the exact same order. There is no intermediate parameter map.

<if>

<if test="email != null">AND email = #{email}</if>

Appends content when the condition is true. Supports nesting, sibling tags, and placement inside <foreach> bodies.

<choose> / <when> / <otherwise>

Only one branch executes, and mutual exclusion is baked into generated bytecode:

<choose>
  <when test="status != null">AND status = #{status}</when>
  <otherwise>AND status = 'NEW'</otherwise>
</choose>
boolean c0 = q.getStatus() != null;
boolean c1 = !c0;                       // <otherwise> is the negation of prior branches

<where> and <set>

<where> adds WHERE only if at least one child condition is true, stripping leading AND/OR automatically. <set> does the same for SET and manages trailing commas:

<update id="rename">
  UPDATE users
  <set>
    <if test="name != null">name = #{name},</if>
    <if test="email != null">email = #{email},</if>
  </set>
  WHERE id = #{id}
</update>
if (c0 | c1) sb.append(" SET");
if (c0) sb.append(c1 ? " name = ?," : " name = ?");
if (c1) sb.append(" email = ?");
sb.append(" WHERE id = ?");

Notice there's no runtime string trimming for trailing commas. The generator already knows which branch comes last for every possible combination and only emits commas when another field follows.

<trim>

Supports literal attributes (prefix, suffix, prefixOverrides, suffixOverrides), which are constant-folded at compile time. (<where> and <set> are simply <trim> with predefined defaults).

The test grammar

This is where we intentionally break from MyBatis: test attributes do not use OGNL. Instead, we use a simple, strictly type-checked grammar validated against your Java method parameters:

Expression Example
Null checks name != null, probe.email == null
Comparisons on typed properties age >= 18, status == 'NEW', id != other.id
Boolean operators and, or, not, parentheses
Collections & strings ids.size() > 0, name.length() > 3, !ids.isEmpty()
Boolean-returning methods user.isActive()
Plain boolean properties active (when active is a boolean/Boolean)

Anything outside this grammar triggers a compile error naming the offending token.

No OGNL truthiness

<if test="count">      <!-- compile error -->
<if test="user">       <!-- compile error -->

MyBatis treats non-null, non-zero, and non-empty values as true. LarkBatis doesn't guess what you meant. Be explicit: write count != 0, user != null, or !list.isEmpty().

This isn't pedantry: in real codebases, test="count" is ambiguous—does it mean "count != null" or "count > 0"? That ambiguity causes real bugs.

Strict null semantics

OGNL coerces types implicitly; our grammar does not. The rules are straightforward:

Expression LarkBatis MyBatis / OGNL
a == null / a != null Null-safe across navigation paths Same
age <= 18 (when age is null) false (null operand makes comparison false) true (null coerced to 0)
a != b Exactly !(a == b) Same
user.isActive() (when user is null) false Throws NPE

Keep row 2 in mind when migrating: null <= 18 was silently true in MyBatis, but evaluates to false in LarkBatis. The migration scanner flags expressions that warrant a look.

Dynamic SQL and statement caches

Queries with dynamic SQL produce multiple distinct SQL strings at runtime. With $N$ independent <if> blocks, there are at most $2^N$ fixed variations known at compile time.

However, ${} splices or varying <foreach> collection sizes produce unbounded SQL variants. These statements include a LarkBatisSql.trackVariants call to monitor cache growth. See Raw SQL.

Testing against MyBatis

Our differential test harness executes identical mappers through both MyBatis and LarkBatis against a recording database, asserting that generated SQL strings and parameter bindings match across both engines.