Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Spring JPA - "java.lang.IllegalArgumentException: Projection type must be an interface!" (using native query)

I'm trying to retrieve a timestamp date from an oracle database but the code is throwing:

java.lang.IllegalArgumentException: Projection type must be an interface!

I'm trying to use native query because the original query is way to complex to use Spring JPA methods or JPQL.

My code is similar to this one below (Sorry, can't paste the original one due to company policy).

Entity:

@Getter
@Setter
@Entity(name = "USER")
public class User {

    @Column(name = "USER_ID")
    private Long userId;

    @Column(name = "USER_NAME")
    private String userName;

    @Column(name = "CREATED_DATE")
    private ZonedDateTime createdDate;
}

Projection:

public interface UserProjection {

    String getUserName();

    ZonedDateTime getCreatedDate();
}

Repository:

@Repository
public interface UserRepository extends CrudRepository<User, Long> {

    @Query(
            value = "   select userName as userName," +
                    "          createdDate as createdDate" +
                    "   from user as u " +
                    "   where u.userName = :name",
            nativeQuery = true
    )
    Optional<UserProjection> findUserByName(@Param("name") String name);
}

I'm using Spring Boot 2.1.3 and Hibernate 5.3.7.

like image 469
Fábio Castilhos Avatar asked Mar 19 '19 18:03

Fábio Castilhos


2 Answers

I had this same issue with a very similar projection:

public interface RunSummary {

    String getName();
    ZonedDateTime getDate();
    Long getVolume();

}

I do not know why, but the issue is with ZonedDateTime. I switched the type of getDate() to java.util.Date, and the exception went away. Outside of the transaction, I transformed the Date back to ZonedDateTime and my downstream code was not affected.

I have no idea why this is an issue; if I don't use projection, the ZonedDateTime works out-of-the-box. I'm posting this as an answer in the meantime because it should be able to serve as a workaround.


According to this bug on the Spring-Data-Commons project, this was a regression caused by adding support for optional fields in the projection. (Clearly it's not actually caused by that other fix -- since that other fix was added in 2020 and this question/answer long predates it.) Regardless, it has been marked as resolved in Spring-Boot 2.4.3.

Basically, you couldn't use any of the Java 8 time classes in your projection, only the older Date-based classes. The workaround I posted above will get around the issue in Spring-Boot versions before 2.4.3.

like image 174
Roddy of the Frozen Peas Avatar answered Nov 10 '22 14:11

Roddy of the Frozen Peas


When you call method from projection interface spring takes the value it received from the database and converts it to the type that method returns. This is done with the following code:

if (type.isCollectionLike() && !ClassUtils.isPrimitiveArray(rawType)) { //if1
    return projectCollectionElements(asCollection(result), type);
} else if (type.isMap()) { //if2
    return projectMapValues((Map<?, ?>) result, type);
} else if (conversionRequiredAndPossible(result, rawType)) { //if3
    return conversionService.convert(result, rawType);
} else { //else
    return getProjection(result, rawType);
}

In the case of getCreatedDate method you want to get java.time.ZonedDateTime from java.sql.Timestamp. And since ZonedDateTime is not a collection or an array (if1), not a map (if2) and spring does not have a registered converter (if3) from Timestamp to ZonedDateTime, it assumes that this field is another nested projection (else), then this is not the case and you get an exception.

There are two solutions:

  1. Return Timestamp and then manually convert to ZonedDateTime
  2. Create and register converter
public class TimestampToZonedDateTimeConverter implements Converter<Timestamp, ZonedDateTime> {
    @Override
    public ZonedDateTime convert(Timestamp timestamp) {
        return ZonedDateTime.now(); //write your algorithm
    }
}
@Configuration
public class ConverterConfig {
    @EventListener(ApplicationReadyEvent.class)
    public void config() {
        DefaultConversionService conversionService = (DefaultConversionService) DefaultConversionService.getSharedInstance();
        conversionService.addConverter(new TimestampToZonedDateTimeConverter());
    }
}

Spring Boot 2.4.0 update:

Since version 2.4.0 spring creates a new DefaultConversionService object instead of getting it via getSharedInstance and I don't know proper way to get it other than using reflection:

@Configuration
public class ConverterConfig implements WebMvcConfigurer {
    @PostConstruct
    public void config() throws NoSuchFieldException, ClassNotFoundException, IllegalAccessException {
        Class<?> aClass = Class.forName("org.springframework.data.projection.ProxyProjectionFactory");
        Field field = aClass.getDeclaredField("CONVERSION_SERVICE");
        field.setAccessible(true);
        GenericConversionService service = (GenericConversionService) field.get(null);

        service.addConverter(new TimestampToZonedDateTimeConverter());
    }
}
like image 11
Nick Avatar answered Nov 10 '22 15:11

Nick