In MyBatis 3, when using foreach to iterate over an Apache Commons Lang Pair collection, the loop variable item may become a String, triggering the "There is no getter for property named 'left'" exception. The root cause is not that Pair lacks getLeft, but that Pair implements Map.Entry; MyBatis binds left/key to index and right/value to item.
If the input is List<Pair<String, String>> and Pair's right happens to be a String, then when accessing #{pair.left}, MyBatis is actually trying to resolve the left property on this String.
This is what the exception is really trying to express:
There is no getter for property named 'left'
in 'class java.lang.String'
The problem is not that Pair#getLeft() doesn't conform to the Java Bean spec. Rather, by the time execution reaches this point, the variable named pair is no longer a Pair at all.
How to Reproduce the Problem
Suppose the Mapper accepts a batch of user-role relationships:
int batchInsert(
@Param("pairs")
List<Pair<String, String>> pairs
);
Called with:
List<Pair<String, String>> pairs = List.of(
Pair.of("1001", "admin"),
Pair.of("1002", "editor")
);
The XML directly reads the two values via left and right:
INSERT INTO user_role (user_id, role_code)
VALUES
<foreach collection="pairs" item="pair" separator=",">
(#{pair.left}, #{pair.right})
</foreach>
Intuitively, in each loop iteration, pair should be:
Pair("1001", "admin")
Pair("1002", "editor")
But that's not what MyBatis actually binds.
Apache Commons Pair Is Also Map.Entry
The type used here is org.apache.commons.lang3.tuple.Pair. Besides providing getLeft() and getRight(), it also implements Map.Entry<L, R>. Apache Commons Lang's official documentation explicitly defines this mapping: key is left, value is right.
Pair.left == Map.Entry.key
Pair.right == Map.Entry.value
Therefore, these four calls can be divided into two groups:
pair.getLeft(); // equivalent to getKey()
pair.getRight(); // equivalent to getValue()
If Pair were just passed to the property resolver as a regular object, both left and right would be accessible. What really changes the behavior is its identity as Map.Entry.
MyBatis foreach Unwraps Map.Entry
MyBatis's <foreach> official documentation explicitly describes two binding modes:
- When iterating over a regular Iterable or array,
indexis the current position number, anditemis the current element; - When iterating over a Map or Map.Entry collection,
indexis the entry's key, anditemis the entry's value.
The processing logic in the current ForEachSqlNode can be simplified to this pseudocode:
for (Object element : iterable) {
if (element instanceof Map.Entry<?, ?> entry) {
bind(indexName, entry.getKey());
bind(itemName, entry.getValue());
} else {
bind(indexName, currentPosition);
bind(itemName, element);
}
}
This branch was originally designed to make Map iteration more natural. For example:
<foreach collection="users" index="userId" item="user">
#{userId}, #{user.name}
</foreach>
When users is Map<String, User>, the key goes into userId and the value goes into user.
The problem is that Apache Commons Pair also satisfies element instanceof Map.Entry.
Why Does Item Become String
Take this data as an example:
Pair.of("1001", "admin")
After entering <foreach>, the variable bindings become:
| Value in Pair | Map.Entry Semantics | foreach Variable |
|---|---|---|
left = "1001" | entry.getKey() | index |
right = "admin" | entry.getValue() | item |
If the XML names the item pair, the full process is:
Pair.of("1001", "admin")
↓
index = "1001"
pair = "admin"
↓
#{pair.left}
↓
Read left property of "admin"
MyBatis then uses the property access mechanism to look for a left getter on String, which of course doesn't exist. Hence the exception mentions class java.lang.String.
This type information is crucial. It shows that the current property resolution target is Pair's right value, not Pair itself. If right were Long, the exception would show Long; if right were another business object, MyBatis would try to resolve properties on that object instead.
Solution 1: Use index and item Directly
Since MyBatis has already unwrapped Pair according to Map.Entry semantics, the minimal change is to directly use the two unwrapped variables:
INSERT INTO user_role (user_id, role_code)
VALUES
<foreach collection="pairs"
index="left"
item="right"
separator=",">
(#{left}, #{right})
</foreach>
At this point:
Pair.left → foreach.index → left
Pair.right → foreach.item → right
One thing to note: here index is no longer a List index like 0, 1, 2, but rather the object returned by Map.Entry#getKey().
This solution is suitable for scenarios with small change scope where Pair is only used temporarily near the Mapper. However, XML readers must understand MyBatis's special semantics for Map.Entry, otherwise index="left" may still feel counterintuitive.
Solution 2: Use a Explicit Parameter Object
If this data has stable business meaning, I'd recommend not letting Pair cross the Mapper boundary.
For example, "user-role relationship" can be defined as a clear parameter object:
public final class UserRoleRow {
private final Long userId;
private final Long roleId;
public UserRoleRow(Long userId, Long roleId) {
this.userId = userId;
this.roleId = roleId;
}
public Long getUserId() {
return userId;
}
public Long getRoleId() {
return roleId;
}
}
Change the Mapper parameter to:
int batchInsert(
@Param("rows")
List<UserRoleRow> rows
);
The XML also returns to the common object property style:
INSERT INTO user_role (user_id, role_id)
VALUES
<foreach collection="rows" item="row" separator=",">
(#{row.userId}, #{row.roleId})
</foreach>
This not only avoids the Map.Entry branch, but also gives the parameter itself business semantics. userId and roleId are usually easier to understand than left and right, and are easier to maintain when field types or validation rules change.
If the upstream code must keep Pair for now, you can convert it before passing to the Mapper:
List<UserRoleRow> rows = pairs.stream()
.map(pair -> new UserRoleRow(pair.getLeft(), pair.getRight()))
.toList();
Not All Types Named Pair Will Trigger This
The determining factor is not whether the type name is Pair, but whether the runtime element implements Map.Entry.
Apache Commons Lang's Pair, ImmutablePair, and MutablePair all enter this branch, because the latter two inherit from Pair. Binary tuple types from other libraries that don't implement Map.Entry will still be bound to item as regular elements.
So when troubleshooting similar problems, rather than just looking at the generic declaration, it's more worth confirming the actual element type and the interfaces it implements.
When Seeing no Getter Exceptions, Check the Actual Type First
Next time you see an exception like this:
There is no getter for property named 'xxx'
in 'class java.lang.String'
And the input parameter is clearly a complex object, check in this order:
- First look at the actual type in the exception, not just the Mapper method signature;
- Confirm what
<foreach>'sitemandindexare bound to; - Check if the collection element implements Map.Entry;
- Then determine whether it's a missing getter or the property resolution target has changed.
The key chain of this problem can be summarized as:
Apache Commons Pair implements Map.Entry
→ MyBatis foreach unwraps as key/value
→ right is bound to item
→ pair.left actually becomes String.left
So it's not a Pair getter compatibility issue, but rather a subtle semantic conflict created when two reasonable interface designs overlap.