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

Saving a `null` Postgres enum results in a varchar value

Open mattmcc-attest opened this issue 1 year ago • 13 comments

Hi, I have an issue with enums using spring-data-jdbc.

I have a Java enum type:

public enum Sentiment {
  POSITIVE,
  NEGATIVE,
  NEUTRAL
}

And a corresponding enum type on one of my tables:

insights=> \dT+ sentiment
                                          List of data types
 Schema |   Name    | Internal name | Size | Elements |    Owner    | Access privileges | Description 
--------+-----------+---------------+------+----------+-------------+-------------------+-------------
 public | sentiment | sentiment     | 4    | POSITIVE+| owner |                   | 
        |           |               |      | NEGATIVE+|             |                   | 
        |           |               |      | NEUTRAL  |             |                   | 
(1 row)

I have this Entity (see the Sentiment field):

@Table("responses")
public record Response(
    @Id UUID id,
    UUID roundId,
    UUID cardTemplateId,
    UUID cardId,
    UUID answerOptionTemplateId,
    UUID answerOptionId,
    String text,
    Sentiment sentiment) {}

If I call save on the repository:

responseRepository.save(new RoundResponse(Id, roundId, UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "text", null)); // sentiment is null

I get the error:

Caused by: org.postgresql.util.PSQLException: ERROR: column "sentiment" is of type sentiment but expression is of type character varying

I have some Converters for handling the enums but because the value is null it doesn't reach these converters. It seems to me that the spring-data-jdbc library must be at some point trying to convert the null value to a varchar? (maybe by default?)

This is not what I would expect, I would expect if the value I am inserting is null and my enum type postgres field is nullable then null should be used rather than varchar :)

Let me know if you need any more information, thank you!

mattmcc-attest avatar Nov 11 '24 12:11 mattmcc-attest

I think it isn't Spring Data JDBC that tries to convert something here. Instead it seems to tell Postgres, this null value is a VARCHAR` and Postgres is overwhelmed by the task to convert it to an enum ...

schauder avatar Nov 11 '24 12:11 schauder

Yeah I debugged into the JdbcTemplate and could see it was trying to insert with sqlType = 12 = varchar. Not sure at what point spring-data-jdbc is telling Postgres is is a varchar though

mattmcc-attest avatar Nov 11 '24 13:11 mattmcc-attest

@schauder Do you think this is a bug? It seems like the MappingJdbcConverter doesn't recognise the type so converts it to a String.

Should there be some function further up the call stack that handles nulls whereby if the value is null then don't try and type it and insert as a null in the DB?

mattmcc-attest avatar Nov 11 '24 13:11 mattmcc-attest

Yes, I do think this is a bug. Could you provide a full reproducer, preferable as github repo?

schauder avatar Nov 12 '24 07:11 schauder

@schauder Thank you :) here is an app that reproduces it https://github.com/mattmcc-attest/animal

mattmcc-attest avatar Nov 15 '24 16:11 mattmcc-attest

^ is the above useful for you? @schauder

mattmcc-attest avatar Nov 26 '24 13:11 mattmcc-attest

Yes, I think this will do. Just need to find time to look into it.

schauder avatar Nov 27 '24 08:11 schauder

no problem, thanks for your help

mattmcc-attest avatar Nov 27 '24 11:11 mattmcc-attest

Hi, everyone! @schauder Are you sure that this is a bug? I wrote some tests in this repo like this

	@Test
	void enumParameterIsNotNullReturnCorrectSqlTypeFromConverter() {

		WithEnumEntity entity = new WithEnumEntity(23L, DummyEnum.ONE);

		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
				singletonList(WritingEnumConverter.INSTANCE));

		SqlIdentifierParameterSource sqlParameterSource = sqlParametersFactory.forInsert(entity,
				WithEnumEntity.class, Identifier.empty(), IdValueSource.PROVIDED);

		assertThat(sqlParameterSource.getValue("id")).isEqualTo(23L);
		assertThat(sqlParameterSource.getValue("dummy_enum")).isEqualTo(DummyEnum.ONE.name());
		assertThat(sqlParameterSource.getSqlType("dummy_enum")).isEqualTo(1111);
	}

	@Test
	void enumParameterIsNullReturnCorrectSqlTypeFromConverter() {
		WithEnumEntity entity = new WithEnumEntity(23L, null);

		SqlParametersFactory sqlParametersFactory = createSqlParametersFactoryWithConverters(
				singletonList(WritingEnumConverter.INSTANCE));

		SqlIdentifierParameterSource sqlParameterSource = sqlParametersFactory.forInsert(entity,
				WithEnumEntity.class, Identifier.empty(), IdValueSource.PROVIDED);

		assertThat(sqlParameterSource.getValue("id")).isEqualTo(23L);
		assertThat(sqlParameterSource.getSqlType("dummy_enum")).isEqualTo(1111);
		assertThat(sqlParameterSource.getValue("dummy_enum")).isNull();
	}

	@WritingConverter
	enum WritingEnumConverter implements Converter<DummyEnum, JdbcValue> {

		INSTANCE;

		@Override
		public JdbcValue convert(DummyEnum source) {
			return JdbcValue.of(source.name().toUpperCase(), JDBCType.OTHER);
		}
	}

The first test is correct because inside converters we have type information by which can find custom converter WritingEnumConverter and convert it.

The second test is incorrect because we don't know what type information for value we have. But even if we do this, for example like this (addConvertedNullableProperty is my custom method)

	if (value == null) {
		TypeInformation<?> typeInformation = property.getTypeInformation();
		addConvertedNullableProperty(parameters, property, typeInformation, paramName);
	} else {
		addConvertedPropertyValue(parameters, property, value, paramName);
	}

And added some new logic where we can find a specific converter, as a result of this convert null object -> null JdbcValue.

Also in the method's documentation by Converter#convert method source must be not null. Hence, MappingJdbcConverter will choose default converter by target type (is a String)

serezakorotaev avatar Jun 03 '25 18:06 serezakorotaev

As a new approach, I can suggest that you set the default JDBC Type.NULL for null values. I tested this approach on https://github.com/mattmcc-attest/animal the repositories and animal were saved correctly.

I can push changes and a few tests so that you can see this approach :)

serezakorotaev avatar Jun 03 '25 18:06 serezakorotaev

Ran into this issue again, is there a known workaround?

Previously I added a sort of NULL option to the enum but now that I am using the database I am not a fan of this, lots of rows have a value now where I would prefer a null. For the most recent problem, I plan to make my column TEXT rather than use enums, perhaps with a check constraint. Again this isn't ideal but means I can have null values in that column

mattmcc-attest avatar Jun 16 '25 11:06 mattmcc-attest

Faced the same issue and made the following workaround.

Created the annotation:

@Documented
@Retention(RUNTIME)
@Target(value = { FIELD })
public @interface ExplicitSqlType {
    JDBCType value();
}

Made a custom converter:

    static class CustomJdbcConverter extends MappingJdbcConverter {
        CustomJdbcConverter(
            RelationalMappingContext context,
            RelationResolver relationResolver, CustomConversions conversions,
            JdbcTypeFactory typeFactory
        ) {
            super(context, relationResolver, conversions, typeFactory);
        }

        @NonNull
        @Override
        public SQLType getTargetSqlType(@NonNull RelationalPersistentProperty property) {
            ExplicitSqlType annotation = property.findAnnotation(ExplicitSqlType.class);
            if (annotation != null) return annotation.value();
            return super.getTargetSqlType(property);
        }
    }

Registered it in my custom JdbcConfiguration:

@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class JdbcConfiguration extends AbstractJdbcConfiguration {

    private final ApplicationContext applicationContext;

    //From SpringBootJdbcConfiguration
    @NonNull
    @Override
    protected Set<Class<?>> getInitialEntitySet() throws ClassNotFoundException {
        return new EntityScanner(applicationContext).scan(Table.class);
    }


    @Bean
    @NonNull
    @Override
    public JdbcConverter jdbcConverter(
        @NonNull JdbcMappingContext mappingContext, @NonNull NamedParameterJdbcOperations operations,
        @Lazy @NonNull RelationResolver relationResolver, @NonNull JdbcCustomConversions conversions, @NonNull Dialect dialect
    ) {
        //From AbstractJdbcConfiguration
        org.springframework.data.jdbc.core.dialect.JdbcArrayColumns arrayColumns = dialect instanceof JdbcDialect jd
            ? jd.getArraySupport()
            : JdbcArrayColumns.DefaultSupport.INSTANCE;
        DefaultJdbcTypeFactory jdbcTypeFactory = new DefaultJdbcTypeFactory(operations.getJdbcOperations(), arrayColumns);

        return new CustomJdbcConverter(mappingContext, relationResolver, conversions, jdbcTypeFactory);
    }
}

Marked my nullable enum fields with the annotation:

    @ExplicitSqlType(JDBCType.OTHER)
    private OrdinalNumber number;

Also with such approach WritingConverter is no longer needed for enums.

avdotius avatar Sep 18 '25 12:09 avdotius

I have exactly the same problem. I have a custom WritingConverter for the enum that uses JDBCType.Other, but it's not invoked on null instances for some reason.

As a workaround on Postgres I'm using

CREATE CAST (varchar AS my_enum) WITH INOUT AS IMPLICIT;

this makes WritingConverter redundant

VariabileAleatoria avatar Sep 30 '25 17:09 VariabileAleatoria