mybatis resultMapping not working when order of field names are changed

240 views Asked by At

I am storing playlist information in an SQLite database. This is my entity class;

@Data
@Builder
public class Playlist {
    private String id;
    private String playlistName;
    private PlaylistType type;
}

The PlaylistType is an enum of ("TYPE_A", "TYPE_B")

At first, in my mapper XML, I tried using resultType="Playlist" and it worked when database column names and Playlist properties were in the same order. But if I change the order of properties, for example as shown below,

public class Playlist {
    private String id;
    private PlaylistType type; // just moved PlaylistType property 
    private String playlistName;
}

It is giving me this error:

Caused by: org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column 'name' from result set.  Cause: java.lang.IllegalArgumentException: No enum constant com.example.demo.PlaylistType.p1

Then some threads suggested to use resultMap, but the issue persists;

This is my mapper XML using resultMap

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.PlaylistRepository">

    <resultMap id="toPlaylist" type="com.example.demo.Playlist">
        <id column="id" property="id"/>
        <result column="name" property="playlistName"/>
        <result column="type" property="type"/>
    </resultMap>
    <select id="findById" resultMap="toPlaylist">
        select * from playlist where id = #{id}
    </select>
</mapper>

Is there a better way to map database columns? It shouldn't be too restrictive in silly matters like the order of properties in the entity class. Or am I missing something?

2

There are 2 answers

0
ave On BEST ANSWER

Adding @NoArgsConstructor is the easiest solution.
This answer is for people who don't want to add no-args constructor to an immutable class.


When you add @Builder to the class, Lombok generates the following constructor.

Playlist(String id, String playlistName, PlaylistType type) {
  this.id = id;
  this.playlistName = playlistName;
  this.type = type;
}

Although it is package-private, MyBatis can (has to; because there is no other constructor) use this constructor using reflection [1].

To map the result to this constructor, MyBatis provides four different methods.

  1. Order-based constructor auto-mapping
  2. Arg-name-based constructor auto-mapping
  3. Result map with <constructor> without name attribute
  4. Result map with <constructor> with name attribute

tl;dr

Method 2 should be sufficient for most simple cases.
Use method 3 or 4 for advanced mapping or when you need the best performance.

1. Order-based constructor auto-mapping

This is the default behavior when you don't use <resultMap>.

In your example, the constructor takes three arguments, so the first, second and third columns in the result set are mapped to id, playlistName and type respectively.
When you change the field order in the class, the order of the constructor arguments changes and you have to change the column order as well.

Personally, I do not recommend this order-based constructor auto-mapping because there is a known issue that can be a head-scratcher.
I explained it in this answer if you are interested.

2. Arg-name-based constructor auto-mapping

This is the behavior when you 1) enable argNameBasedConstructorAutoMapping in the config and 2) don't use <resultMap>. With this method, MyBatis looks for a column that has the same name as the constructor argument [2].
The column order does not matter.

Note that, in your example, the column name name does not match the target argument name playlistName, so you may have to specify a column alias in the SELECT statement.

argNameBasedConstructorAutoMapping was added in version 3.5.10.

3. Result map with <constructor> without name attribute

When using a result map, you need <constructor>, <idArg> and <arg> elements to perform constructor mapping.

<resultMap>
  <constructor>
    <idArg column="id" javaType="string" />
    <arg column="name" javaType="string" />
    <arg column="type" javaType="pkg.PlaylistType" />
  </constructor>
</resultMap>

For the sake of completeness, here is the same result map declared in a Java mapper [3].

@Arg(id = true, column = "id", javaType = String.class)
@Arg(column = "name", javaType = String.class)
@Arg(column = "type", javaType = PlaylistType.class)
Playlist findById(String id);

With this method, the column order does not matter, but the XML element order must match the constructor argument order.
In case it is difficult for you to maintain the XML element order (e.g. the target class is frequently updated), see the next section.

4. Result map with <constructor> with name attribute

When name attribute is specified, the order of XML elements does not have to match the order of the actual constructor arguments [4].

<resultMap>
  <constructor>
    <idArg column="id" name="id" javaType="string" />
    <arg column="name" name="playlistName" javaType="string" />
    <arg column="type" name="type" javaType="pkg.PlaylistType" />
  </constructor>
</resultMap>

In your case, the constructor argument types always match the field types, so javaType can be omitted.

<resultMap>
  <constructor>
    <idArg column="id" name="id" />
    <arg column="name" name="playlistName" />
    <arg column="type" name="type" />
  </constructor>
</resultMap>

And here is the same result map using annotation.

@Arg(id = true, column = "id", name = "id")
@Arg(column = "name", name = "name")
@Arg(column = "type", name = "type")
Playlist findById(String id);

With the above result map, MyBatis searches a constructor that has the following three arguments, but in arbitrary order.

  • name: id, type=java.lang.String
  • name: playlistName, type=java.lang.String
  • name: type, type=pkg.PlaylistType

When there are multiple constructors that match the criteria, you need to add @AutomapConstructor to the right one.
Once the constructor is found, the value of the specified column is mapped to each constructor argument.

So, with this method, both XML element order and column order do not matter, but you may need to edit the name value if you change a field name.

This method requires version 3.4.3 or later.


[1] If you use the Java Platform Module System (JPMS), you may have to allow MyBatis to access this constructor.

[2] To include argument names in the binary, you must either 1) specify -parameters compiler option or 2) add @Param annotation to each argument.

[3] If you use a version older than 3.5.4, you may need @ConstructorArgs.

[4] <idArg> must be written before <arg> because it is enforced by the DTD.

5
mig001 On

I can't believe I made the same mistake in a slightly different way. For my benefit and others let me share some insights.

The issue looks like it appears from mybatis, but it is not. The Lombok annotations are the ones causing the problem. The actual mapping operation in this case can be traced to

 private boolean applyColumnOrderBasedConstructorAutomapping(ResultSetWrapper rsw, List<Class<?>> constructorArgTypes,
      List<Object> constructorArgs, Constructor<?> constructor, boolean foundValues) throws SQLException {
    for (int i = 0; i < constructor.getParameterTypes().length; i++) {
      Class<?> parameterType = constructor.getParameterTypes()[i];
      String columnName = rsw.getColumnNames().get(i);
      TypeHandler<?> typeHandler = rsw.getTypeHandler(parameterType, columnName);
      Object value = typeHandler.getResult(rsw.getResultSet(), columnName);
      constructorArgTypes.add(parameterType);
      constructorArgs.add(value);
      foundValues = value != null || foundValues;
    }
    return foundValues;
  }

From org/apache/ibatis/executor/resultset/DefaultResultSetHandler.java#L783

As can be seen, constructor parameters are taken one by one hence the order of properties in the entity class is important. This is because the @Builder annotation will create an all-arguments constructor. Hence mybatis can't use reflection to map the parameters since it has no default constructor (This checking is done at 686th line of DefaultResultSetHandler.java). Instead, it would iterate over constructor parameters and set the values. This is why the order is important.

The solution is simple, just add @NoArgsConstructor and @AllArgsConstructoron top of the Playlist class.