spring-data-jpa icon indicating copy to clipboard operation
spring-data-jpa copied to clipboard

Returning Converted entity properties cause DTO projection rewrite

Open hirth-abi opened this issue 5 months ago • 8 comments

When a Spring Data JPA query returns a view (interface or just a single property), it is now required that converted properties provide a redundant constructor with their own class as parameter. E.g.

public interface DemoRepository extends JpaRepository<DemoEntity, Long> {
    @Query("SELECT e.valueObject1 FROM DemoEntity e")
    DemoValueObject1 findFirstValueObject1();
}

the DemoValueObject1 (mapped by an AttributeConverter to string) needs such a really redundant ctor:

public class DemoValueObject1 {

    private String value;

    public DemoValueObject1(DemoValueObject1 value) {
        this.value = value.getValue();
    }
}

otherwise it fails with org.hibernate.query.SemanticException: Missing constructor for type 'DemoValueObject2' [SELECT new com.example.demo.DemoValueObject2(e.valueObject2) FROM DemoEntity e]

Here is the full minimal sample application to reproduce it: https://github.com/hirth-abi/spring_boot_353_2/blob/main/src/test/java/com/example/demo/DemoRepositoryTest.java

Tested with Spring Boot 3.5.3, this was not required in Spring Boot 3.4.

hirth-abi avatar Jul 02 '25 13:07 hirth-abi

Same problem here. Worked fine until 3.5.0, but after 3.5.1, it no longer works.

marjonrafael avatar Jul 02 '25 15:07 marjonrafael

We rewrite queries to use constructor expressions when using a DTO projection. Returning DemoValueObject1 from a repository whose domain type is DemoEntity is considered a projection. We however back off from DTO rewriting if the returned type is managed by JPA (by introspecting Metamodel.getManagedTypes()).

Thanks for the reproducer, I'm going to check why the issue still persists after fixing #3895.

mp911de avatar Jul 03 '25 09:07 mp911de

Hi, I have a similare problem with a query where the Country property hasn't no arg constructor

@QueryHints(@QueryHint(name` = HINT_FETCH_SIZE, value = "100"))
@Query("select distinct cv.id.country as country from CountryValueEntity cv order by country")
    Stream<Country> findDistinctCountries();

Country class without no arg constructor

public class Country implements Serializable, Comparable<Country> {

    private final String country;

    private Country(String country) {
        this.country = country.toUpperCase();
    }

    public static Country of(@NonNull String stringCountry) {
        return new Country(stringCountry);
    }

    public String getValue() {
        return country;
    }

    @Override
    public String toString() {
        return country;
    }

    @Override
    public int compareTo(Country o) {
        return getValue().compareTo(o.getValue());
    }
}

I have the same error message : org.hibernate.query.SemanticException: Missing constructor for type 'Country' [select distinct new com.demo.springboot.type.Country(cv.id.country) from CountryValueEntity cv order by country]

Here the sample repository to reproduce the problem : https://github.com/ThibautCantet/springboot-353

ThibautCantet avatar Jul 03 '25 13:07 ThibautCantet

So far we have the following usages that seem affected:

  • Hibernate BaseUserTypeSupport
  • @Convert

mp911de avatar Jul 03 '25 14:07 mp911de

The JPA metamodel doesn't contain details about classes that are converted. This is opening up an entirely different problem space.

As workaround, you can introduce another constructor (that is visible to Hibernate) accepting a Country object:

public class Country implements Serializable, Comparable<Country> {

    private final String country;

    private Country(String country) {
        this.country = country.toUpperCase();
    }

    public Country(Country other) {
        this.country = other.country;
    }

    // …
}

For the time being, we will add a check on whether we can generally resolve a constructor or not. Just the mere presence of another constructor will let Spring Data JPA back off after the next service release, however, that won't be a fix addressing the underlying problem as we don't have access to the converter information.

Likely, a complete fix might require parsing of the entire query and analyzing where a select item comes from to determine its type.

mp911de avatar Jul 07 '25 08:07 mp911de

Encountered this issue when working with JSON columns in PostgreSQL. Given an entity with a field mapped to a different type:

@Entity(name = "item_prices")
data class ItemPriceRecord(
    // Other properties, including @Id

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "json", columnDefinition = "jsonb")
    val item: ItemPricingDetails,
)

data class ItemPricingDetails(
    // Just a regular data class with some properties.
    // Nothing interesting and it used to work before the upgrade to 3.5.2
)

And a repository:

@Repository
interface ItemPriceRepository : JpaRepository<ItemPriceRecord, ItemPriceRecordId>, JpaSpecificationExecutor<ItemPriceRecord> {
    // Note that this method returns ItemPricingDetails, not the ItemPriceRecord by selecting the "item" JSON column.
    // It used to work previously
    @Query("select i.item from item_prices i where …")
    override fun getItemPricesByXXX(…): List<ItemPricingDetails>
}

We're getting the SemanticException after the upgrade:

Caused by: org.hibernate.query.SemanticException: Missing constructor for type 'ItemPricingDetails' [select new a.b.c.ItemPricingDetails(i.item) from item_prices i where i.countryCode = :countryCode and i.productId in :productIds]
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitInstantiation(SemanticQueryBuilder.java:1511)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitInstantiation(SemanticQueryBuilder.java:277)
	at org.hibernate.grammars.hql.HqlParser$InstantiationContext.accept(HqlParser.java:4034)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSelectableNode(SemanticQueryBuilder.java:1458)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSelection(SemanticQueryBuilder.java:1412)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSelectClause(SemanticQueryBuilder.java:1405)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuery(SemanticQueryBuilder.java:1254)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuerySpecExpression(SemanticQueryBuilder.java:1040)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuerySpecExpression(SemanticQueryBuilder.java:277)
	at org.hibernate.grammars.hql.HqlParser$QuerySpecExpressionContext.accept(HqlParser.java:2134)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSimpleQueryGroup(SemanticQueryBuilder.java:1025)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSimpleQueryGroup(SemanticQueryBuilder.java:277)
	at org.hibernate.grammars.hql.HqlParser$SimpleQueryGroupContext.accept(HqlParser.java:2005)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSelectStatement(SemanticQueryBuilder.java:492)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitStatement(SemanticQueryBuilder.java:451)
	at org.hibernate.query.hql.internal.SemanticQueryBuilder.buildSemanticModel(SemanticQueryBuilder.java:324)
	at org.hibernate.query.hql.internal.StandardHqlTranslator.translate(StandardHqlTranslator.java:71)
	at org.hibernate.query.internal.QueryInterpretationCacheStandardImpl.createHqlInterpretation(QueryInterpretationCacheStandardImpl.java:145)
	at org.hibernate.query.internal.QueryInterpretationCacheStandardImpl.resolveHqlInterpretation(QueryInterpretationCacheStandardImpl.java:132)
	at org.hibernate.query.spi.QueryEngine.interpretHql(QueryEngine.java:54)
	at org.hibernate.internal.AbstractSharedSessionContract.interpretHql(AbstractSharedSessionContract.java:832)
	at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:878)
	... 46 common frames omitted

Are there any plans to fix this? Or should we implement a workaround?

madhead avatar Aug 07 '25 23:08 madhead

Is there going to be a fix for this in 3.5.7 @mp911de? I just built a SNAPSHOT from sources and the current state of the 3.5.x branch unfortunatelly does not fix the problem. I debugged the internals to check where exactly the problem came from and for us it seems to be the change in the method org.springframework.data.jpa.repository.query.AbstractStringBasedJpaQuery#getReturnedType(ResultProcessor processor) starting at version 3.5.1. There, the following code block at the end was removed:

String alias = query.getAlias();
		String projection = query.getProjection();

		// we can handle single-column and no function projections here only
		if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) {
			return returnedType;
		}

		if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) {
			alias = alias.trim();
			projection = projection.trim();
			if (projection.startsWith(alias + ".")) {
				projection = projection.substring(alias.length() + 1);
			}
		}

		if (StringUtils.hasText(projection)) {

			int space = projection.indexOf(' ');

			if (space != -1) {
				projection = projection.substring(0, space);
			}

			Class<?> propertyType;

			try {
				PropertyPath from = PropertyPath.from(projection, getQueryMethod().getEntityInformation().getJavaType());
				propertyType = from.getLeafType();
			} catch (PropertyReferenceException ignored) {
				propertyType = null;
			}

			if (propertyType == null
					|| (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) {
				knownProjections.put(returnedJavaType, false);
				return new NonProjectingReturnedType(returnedType);
			} else {
				knownProjections.put(returnedJavaType, true);
			}
		}

		return returnedType;

Before the adjustments the expression

if (propertyType == null || (returnedJavaType.isAssignableFrom(propertyType) ||propertyType.isAssignableFrom(returnedJavaType)))

evaluated to true and an NonProjectingReturnedType object was returned. Now the method just returns returnedType and therefore the error from above results.

The use of a copy constructor is not a feasible for our project, since it would cause a massive overhead. Additionally, it does not solve all problems to use a copy constructor, since we rely on specific object instances to be returned and with a copy constructor you would always create a new instance.

Antosch avatar Dec 09 '25 12:12 Antosch

Is there going to be a fix for this in 3.5.7

No, I don't think so, see https://github.com/spring-projects/spring-data-jpa/issues/3929#issuecomment-3044026015

As of now, we don't have the bandwidth nor reasonable contributions to solve this issue.

mp911de avatar Dec 09 '25 13:12 mp911de

@mp911de Let me know if you'd accept PR #4129 on this, which basically checks the hibernate type registry for basic types.

This solves the problem I've reported as duplicate #4128.

If this solution is acceptable to you I'd work on the needed test cases.

joshiste avatar Dec 19 '25 20:12 joshiste