Search code examples
javahibernate-6.xspring-boot-3

Hibernate 6 + attribute of class persisted as String


I face an issue with Hibernate 6 (migration to Spring Boot 3). I have a main entity class BaseEntity with a status attribute of class CustomStatus. In hibernate/JPA query, I like to have this syntax select * from BaseEntity where status = 'ARCHIVED' or select * from BaseEntity where status NOT IN ('ARCHIVED', 'CANCELLED'). That works well with native query, but I like to use JPA to be able to call service.

Compile is fine, but at runtime, I get an error on query execution:

Can't compare test expression of type [BasicSqmPathSource(status : CustomStatus)] with element of type [basicType@4(java.lang.String,12)].


Initially (Hibernate 5 / Spring Boot 2.7), @Type referencing a AbstractSingleColumnStandardBasicType / AbstractTypeDescriptor was doing job perfectly.


BaseEntity

@Table(name="base_entity")
public class BaseEntity {
    @Id
    private Integer id;

    @JavaType(CustomStatusJavaType.class)
    @JdbcTypeCode(SqlTypes.VARCHAR)
    private CustomStatus status;
}

CustomStatus

public class CustomStatus implements Serializable {
    private final String name;

    public CustomStatus (final String name) { this.name = name;}
}

CustomStatusJavaType

public class CustomStatusJavaType extends AbstractClassJavaType<CustomStatus> {

    public static final CustomStatusJavaType INSTANCE = new CustomStatusJavaType ();

    public CustomStatusJavaType () {
        super(CustomStatus.class);
    }

    @Override
    public String toString(CustomStatus value) {
        if (value == null) return null;
        return value.getName();
    }

    @Override
    public CustomStatus fromString(CharSequence str) {
        if (str == null || str.length() == 0) return null;
        return new CustomStatus(str.toString());
    }

    @Override
    public <X> X unwrap(CustomStatus value, Class<X> type, WrapperOptions options) {
        if (value == null) return null;
        return StringJavaType.INSTANCE.unwrap(
                value == null ? null : value.getName(),
                type,
                options
        );
    }

    @Override
    public <X> CustomStatus wrap(X value, WrapperOptions options) {
        if (value == null) return null;
        return new CustomStatus (StringJavaType.INSTANCE.wrap( value, options));
    }
}

UPDATE with AttributeConverter

CustomStatus

@Converter
public class CustomStatusAttributeConverter implements AttributeConverter<CustomStatus, String> {

    @Override
    public String convertToDatabaseColumn(CustomStatus status) {
        return (null == status? null : status.getName());
    }

    @Override
    public CustomStatus convertToEntityAttribute(String s) {
        return (s == null || s.isEmpty() ? null : new CustomStatus(s));
    }
}
public class BaseEntity {
    @Id
    private Integer id;

    @Convert(converter = CustomStatusAttributeConverter.class)
    private CustomStatus status;
}

Alas I get same error...It works if i write a native query, but not with JPA query.


Solution

  • I think the problem is your query, select * from BaseEntity where status = 'ARCHIVED'/select * from BaseEntity where status NOT IN ('ARCHIVED', 'CANCELLED').

    To be more exact, hibernate is not expecting Strings but CustomStatus in your query.

    If you don't want to use a native query, you could try using placeholders in your query:

    @Query("select * from BaseEntity where status = :status")
    List<BaseEntity> getBaseEntitiesWithStatus(CustomStatus status);
    default List<BaseEntity> getArchivedBaseEntities(){
        return getBaseEntitiesWithStatus(new CustomStatus("ARCHIVED"));
    }
    
    @Query("select * from BaseEntity where status NOT IN :disallowed")
    List<BaseEntity> getBaseEntitiesWhereStatusNotIn(List<CustomStatus> disallowed);
    default List<BaseEntity> getActiveBaseEntities(){
        return getBaseEntitiesWhereStatusNotIn(List.of(
            new CustomStatus("ARCHIVED"),
            new CustomStatus("CANCELLED")
        ));
    }
    

    You could also try representing your CustomStatus differently, e.g. as it's own entity in a 1:N relation or by embedding it:

    public class BaseEntity {
        @Id
        private Integer id;
    
        @Embedded
        private CustomStatus status;
        //methods
    } 
    @Embeddable
    public class CustomStatus implements Serializable {
        private final String name;
    
        public CustomStatus (final String name) { this.name = name;}
        //methods
    }
    

    With this, you could probably make a query like select * from BaseEntity where status.name = 'ARCHIVED'

    However, that might require making name non-final.