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 >= #{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
}
}
- Each
testcondition is evaluated once into a local boolean. The same boolean drives both SQL assembly and parameter binding, so they can never disagree. StringBuilderinitial capacity is computed at build time from the maximum possible query length.<where>turns into a simple conditional append rather than a runtime string scan for leadingAND/OR.- The leading
AND/ORstrip rule is constant-folded: the first active branch omitsAND. It's a simple ternary expression over precomputed booleans. - Parameter binding walks the exact same conditions in the exact same order. There is no intermediate parameter map.
<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
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.