Raw SQL and SqlFragment¶
#{} creates a JDBC bind parameter (?). ${} splices text directly into raw SQL—which is where SQL injection risks come from. LarkBatis does not ban ${} outright because dynamic sorting or schema targeting are legitimate requirements. Instead, it forces every splice through types the compiler can verify and you can easily grep for.
The Rule¶
Binding a plain String to ${} is a compile error
@Select("SELECT id, name FROM users ORDER BY ${sort}")
List<User> all(String sort); // compile error
The compiler rejects this and lists the supported types.
Type for ${} |
Why it is safe |
|---|---|
SqlFragment |
Created via audited factory methods (allowed, identifier, unsafeRawSql) |
Closed-value types (int, long, boolean, enum) |
Finite value ranges that cannot introduce SQL injection |
String with @OrderBy(allowed = {...}) |
Compiled to a strict Java switch statement over string literals |
@OrderBy¶
The most common use case for dynamic SQL text is column sorting. You can declare allowed column names directly:
@Select("SELECT id, name, email, created_at FROM users ORDER BY ${sort}")
List<User> all(@OrderBy(allowed = {"id", "name", "created_at"}) String sort);
The generator emits a switch statement validating the input against this list. Any unrecognized string throws a LarkBatisRejectedException immediately, preventing invalid input from ever touching the database.
SqlFragment¶
When building dynamic SQL fragments in Java, use SqlFragment factories:
SqlFragment.allowed(value, "created_at", "name") // (1)!
SqlFragment.identifier(value) // (2)!
SqlFragment.unsafeRawSql(value) // (3)!
SqlFragment.allowed(...): Validates against a static whitelist. (Recommended).SqlFragment.identifier(...): Validates that the input is a valid SQL identifier (alphanumeric and underscores only). Ideal for dynamic table or schema names.SqlFragment.unsafeRawSql(...): Slices arbitrary text into SQL without checks. This serves as the single searchable audit point for unescaped SQL across your entire codebase.
@Select("SELECT id, name FROM users WHERE ${predicate} ORDER BY id")
List<User> where(SqlFragment predicate);
Auditing raw SQL with grep¶
Because raw SQL creation is channeled through unsafeRawSql, security audits are straightforward:
In standard MyBatis, ${} expressions and @SelectProvider methods are scattered across XML files and Java classes, requiring full codebase reviews to detect vulnerabilities.
Tracking SQL variants¶
Both database engines and JDBC drivers cache execution plans by query text. Unbounded ${} splices or fluctuating <foreach> sizes can flood statement caches with thousands of distinct query strings.
LarkBatis automatically monitors statements with dynamic SQL:
If a statement exceeds the configured variant threshold, LarkBatis logs a warning:
larkbatis:
max-sql-variants: 64 # default threshold
fail-on-unbounded-fragment: false # default
Enable fail-on-unbounded-fragment in CI/testing
Set larkbatis.fail-on-unbounded-fragment: true in your test environment to catch unbounded query generation before deploying to production.
To structurally bound <foreach> variants, use @PadPow2. See foreach and Batches.
Dynamic columns in select lists¶
Using ${} inside a SELECT column list prevents the compiler from predicting column positions. The statement automatically falls back to reading columns by name from ResultSetMetaData on the first row. The build outputs a note whenever this fallback occurs.
The manual escape hatch¶
When you need to construct complex queries dynamically in Java, write a default method on your mapper interface:
default List<User> recent(LarkBatisSession s, int limit) {
return s.query(
SqlFragment.unsafeRawSql(
"SELECT id, name, email, created_at FROM users"
+ " ORDER BY created_at DESC LIMIT " + limit),
ps -> { }, // (1)!
UserRow.READER); // (2)!
}
StatementBinderlambda: Bind JDBC parameters safely usingps.setXxxrather than string concatenation.- Generated
UserRow.READER: Reads rows positionally with zero reflection.
Core execution methods:
| Method | Return Type |
|---|---|
s.query(SqlFragment, StatementBinder, RowReader<T>) |
List<T> |
s.queryOne(SqlFragment, StatementBinder, RowReader<T>) |
T (or null) |
s.queryStream(SqlFragment, StatementBinder, RowReader<T>) |
Stream<T> (caller closes) |
s.update(SqlFragment, StatementBinder) |
int (affected rows) |
If you need a row reader for a class that isn't referenced in any mapper statement, annotate it with @LarkBatisRow:
@LarkBatisRow
public class DomainCount {
private String domain;
private long total;
// standard getters and setters
}
default List<DomainCount> countByDomain(LarkBatisSession s, int minimum) {
return s.query(
SqlFragment.unsafeRawSql("SELECT domain, COUNT(*) AS total FROM contacts"
+ " GROUP BY domain HAVING COUNT(*) >= ?"),
ps -> ps.setInt(1, minimum),
DomainCountRow.READER);
}
DomainCountRow.READER reads columns positionally based on property declaration order. If your custom SQL returns columns in a different order, use name-based reading: