Search code examples
jpa

Strange behavior in columns that are not null and have default values (SpringBoot JPA)


Discovering a strange behavior during SpringBoot JPA testing Please advise. What I want is a column that has a default value and is not null. I created it like this

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Entity
@DynamicInsert
public class Board {

    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    private Long seq;

    @Column(length = 320, nullable = false)
    @ColumnDefault("'system'")
    /* i don't won't it this way */
//    @Builder.Default
//    private String usrId = "system";
    private String usrId;
}

@SpringBootApplication
public class SpringBootJpaTestApplication {

    private static final Logger log = LoggerFactory.getLogger(SpringBootJpaTestApplication.class);
    
    public static void main(String[] args) {
        SpringApplication.run(SpringBootJpaTestApplication.class, args);
    }

    @Bean
    CommandLineRunner demo(TestRepository repository) {
        return (args) -> {
            
            for(int i = 0; i < 1; i++) {
//                Test test = Test.builder().build();
                Test test = new Test();
                test.setTitle("test title " + i);
                test.setContent("test content " + i);
                
                System.out.println(test.toString());
                
                repository.save(test);
            }
            
            repository.findAll().forEach(test -> {
                log.info(test.toString());
            });
            
            repository.findByTitle("test").forEach(bbs -> {
                log.info(bbs.toString());
            });
            
        };
    }

}

This seemed to work fine. However, if you comment out the following, an error occurs

<!--
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
-->

Error description

//--------------------------------------------------------------------------------

Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
[2m2024-03-14T23:09:48.901+09:00[0;39m [31mERROR[0;39m [35m2776[0;39m [2m---[0;39m [2m[SpringBootJpaTest] [           main][0;39m [2m[0;39m[36mo.s.boot.SpringApplication              [0;
39m [2m:[0;39m Application run failed

org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : com.demo.SpringBootJpa.Test.usrId
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:307) ~[spring-orm-6.1.4.jar:6.1.4]
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:241) ~[spring-orm-6.1.4.jar:6.1.4]
    at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550) ~[spring-orm-6.1.4.jar:6.1.4]
    at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61) ~[spring-tx-6.1.4.jar:6.1.4]
    at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:335) ~[spring-tx-6.1.4.jar:6.1.4]
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152) ~[spring-tx-6.1.4.jar:6.1.4]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.4.jar:6.1.4]
    at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:164) ~[spring-data-jpa-3.2
.3.jar:3.2.3]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.4.jar:6.1.4]
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) ~[spring-aop-6.1.4.jar:6.1.4]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.4.jar:6.1.4]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:220) ~[spring-aop-6.1.4.jar:6.1.4]
    at jdk.proxy2/jdk.proxy2.$Proxy94.save(Unknown Source) ~[na:na]
    at com.skywhalelab.SpringBootJpa.SpringBootJpaTestApplication.lambda$0(SpringBootJpaTestApplication.java:31) ~[classes/:na]
    at org.springframework.boot.SpringApplication.lambda$callRunner$5(SpringApplication.java:790) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.util.function.ThrowingConsumer$1.acceptWithException(ThrowingConsumer.java:83) ~[spring-core-6.1.4.jar:6.1.4]
    at org.springframework.util.function.ThrowingConsumer.accept(ThrowingConsumer.java:60) ~[spring-core-6.1.4.jar:6.1.4]
    at org.springframework.util.function.ThrowingConsumer$1.accept(ThrowingConsumer.java:88) ~[spring-core-6.1.4.jar:6.1.4]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:798) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:789) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.boot.SpringApplication.lambda$callRunners$3(SpringApplication.java:774) ~[spring-boot-3.2.3.jar:3.2.3]
    at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:183) ~[na:na]
    at java.base/java.util.stream.SortedOps$SizedRefSortingSink.end(SortedOps.java:357) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499) ~[na:na]
    at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:150) ~[na:na]
    at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:173) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) ~[na:na]
    at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596) ~[na:na]
    at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:774) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:341) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1354) ~[spring-boot-3.2.3.jar:3.2.3]
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1343) ~[spring-boot-3.2.3.jar:3.2.3]
    at com.skywhalelab.SpringBootJpa.SpringBootJpaTestApplication.main(SpringBootJpaTestApplication.java:16) ~[classes/:na]

//--------------------------------------------------------------------------------

Can you tell me why this is happening? Of course, I know that if I use Builder.Default and give it a default value, it works fine. But that's not the direction I want to go. I want to handle it so that if usrId goes to null, I don't get an error and the DB puts in the Default value.

Thank you in advance.




Thank you for your answer.

Let me clarify my question further.

In JPA, if the @DynamicInsert annotation is present and the Entity has a field with a null value, it will not be included when inserting.

It works like this

Hibernate: 
    create table test (
        del_yn varchar(1) default 'N' not null,
        dth integer default 0,
        rd_cnt integer default 0 not null,
        bbs_cd varchar(8) default 'NONE',
        cre_dt datetime(6) default sysdate() not null,
        mod_dt datetime(6),
        seq bigint not null,
        upr_seq bigint,
        title varchar(128),
        usr_id varchar(320) default 'system' not null,
        content tinytext,
        primary key (seq)
    ) engine=InnoDB
[2m2024-03-16T07:51:16.144+09:00[0;39m [32m INFO[0;39m [35m6540[0;39m [2m---[0;39m [2m[SpringBootJpaTest] [           main][0;39m [2m[0;39m[36mj.LocalContainerEntityManagerFactoryBean[0;
39m [2m:[0;39m Initialized JPA EntityManagerFactory for persistence unit 'default'
[2m2024-03-16T07:51:16.470+09:00[0;39m [32m INFO[0;39m [35m6540[0;39m [2m---[0;39m [2m[SpringBootJpaTest] [           main][0;39m [2m[0;39m[36mc.s.S.SpringBootJpaTestApplication      [0;
39m [2m:[0;39m Started SpringBootJpaTestApplication in 7.923 seconds (process running for 8.568)
Test(seq=null, bbsCd=null, title=test title 0, content=test content 0, uprSeq=null, rdCnt=null, dth=null, delYn=null, usrId=null, creDt=null, modDt=null)
Hibernate: 
    select
        next value for test_seq
Hibernate: 
    insert 
    into
        test
        (content, title, seq) 
    values
        (?, ?, ?)

This works perfectly fine.

In other words, if usrId is Null, it is not included in the Insert syntax.

This is because of the @DynamicInsert annotation.

However, if we remove the spring-boot-starter-validation dependency, we get an error.

Here is what is commonly known

  1. if [nullable = false] in the @Column annotation, make the table column [not null].

  2. if entity have the @DynamicInsert annotation, if the value of a particular field is Null, remove that field from the Insert syntax.

The difference between having spring-boot-starter-validation in pom.xml and not having it is as follows

The prerequisites are

  1. the @DynamicInsert annotation is declared.
  • If the @DynamicInsert annotation is not declared, ColumnDefault is meaningless.
  1. the column has [nullable = false] declared.
  • A specific column must be created as [not null].
  1. the corresponding field (in this case usrId) is null.

In this case, the following happens

  1. spring-boot-starter-validation, if present, will not check for null.
  • Since there is a DynamicInsert annotation, usrId is not included in the Insert syntax and the default value "system" is inserted.
  1. if spring-boot-starter-validation is not present, it checks null and raises an error.

For example, this column behaves as follows when null.

@Column(nullable = false)
private String testColumn;

without spring-boot-starter-validation, it will be checked for not-null and an error will be thrown. Same behaviour as above.

If spring-boot-starter-validation is present, it is not included in the Insert syntax due to the @DynamicInsert annotation.

However, the table column is not assigned Default, so an error is thrown.

org.springframework.orm.jpa.JpaSystemException: could not execute statement [(conn=1624) Field 'test_column' doesn't have a default value] [insert into test (content,title,seq) values (?,?,?)]
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:341) ~[spring-orm-6.1.4.jar:6.1.4]
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:241) ~[spring-orm-6.1.4.jar:6.1.4]
    at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:565) ~[spring-orm-6.1.4.jar:6.1.4]

To summarize, my question is this.

  1. the entity has the @DynamicInsert annotation declared.

  2. a certain field has @Column(nullable = false) declared on it.

  3. the specific field value is null.

  • If the spring-boot-starter-validation dependency is not present in the pom.xml, the null check will result in an error.

  • If there is a spring-boot-starter-validation dependency in the pom.xml, it does not check null.

Any advice would be greatly appreciated.

I've uploaded the source I tested to git.

If you could take a look and give me some advice, it would be greatly appreciated :)

git: github.com/jykim31337/SpringBootJpaTest.git


Solution

  • I also didn't quite understand the intent of the problem. thank you :)

    The cause of the problem lies in the hibernate.check_nullability check method.

    @Column(nullable=false) is a Hibernate annotation.

    • To conclude, the annotation is involved in validation.
    • It may be tricky, but if you refer to the Hibernate official document, there is a property called hibernate.check_nullability.
      • The main thing is that if Bean Validation is on the class path (i.e.'org.springframework.boot:spring-boot-starter-validation') and the Hibernate annotation is used, the default value is false, no validation is made. The opposite is true, the validation is made.
    • In summary, spring-boot-starter-validation(hibernate) has been added and @Column(nullable=false) has been applied, so @DynamicInsert is applied without null check.
    • On the other hand, when spring-boot-starter-validation is deleted, Bean Validation is not in the classpath, so Hibernate checks for nullability and throws a PropertyValueException exception.

    I verified it with the example below. :)

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        compileOnly 'org.projectlombok:lombok'
        runtimeOnly 'com.h2database:h2'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
    
    //    implementation 'org.springframework.boot:spring-boot-starter-validation'
    }
    
    @Getter
    @NoArgsConstructor
    @Entity
    @DynamicInsert
    public class Board {
    
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Long seq;
    
        @Column(length = 320, nullable = false)
        @ColumnDefault("'system'")
        private String usrId;
    }
    

    Create a custom Hibernate configuration class, change the check_nullability property to false, and run it.

    @Configuration
    public class HibernateConfig {
    
        @Bean
        public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
            return hibernateProperties -> hibernateProperties.put(Environment.CHECK_NULLABILITY, false);
        }
    }
    
    //skip logic...
    Board board = new Board();
    boardRepository.save(board);
    //...
    
    • @DynamicInsert is applied.
    Hibernate: 
        select
            next value for board_seq
    Hibernate: 
        insert 
        into
            board
            (seq) 
        values
            (?)
    Hibernate: 
        select
            b1_0.seq,
            b1_0.usr_id 
        from
            board b1_0
    

    I hope it will be of help.


    • From a DBMS perspective, it is natural that NULL constraints have priority over default.
      • DBMS is a system that places data integrity above all else.

    Let's briefly summarize the flow with and without the NULL constraint below.

    • nullable = true(NULL constraint X)
      • usrId(null) → DBMS → usrID(system) → INSERT
    • nullable = false (NULL constraint O)
      • usrId(null) → DBMS → exception!(Do not enter null!)

    However, there is something that can be a bit confusing.

    • Column default enters the corresponding value as default if the column is not entered in the insert query.
      • This is completely different from changing a null value to a default value.
    • Let’s see the differences below.
    INSERT INTO BOARD(SEQ) VALUES(1); //this case insert default value in USR_ID
    INSERT INTO BOARD(SEQ, USR_ID) VALUES(4, null); //NULL not allowed for column
    
    • If you try to input a null value into a SQL query, a NULL not allowed for column error will occur.
      • This is because it violates the NULL constraint.

    In conclusion, JPA does not work as the questioner intended.

    • This is because JPA also uses the same exception handling as DBMS.
      • I think this is to ensure system stability by handling exceptions in advance at the application stage before a request goes to the DBMS and an error occurs.

    Recommended solution

    • First, when creating an object, I prefer to create and use the object by setting the initial value of the corresponding value in the constructor.
      • Reason 1: It is more object-oriented.
      • Reason 2: It does not depend on JPA, but rather on programming languages, so it is not dependent on any specific technology.
    @Getter
    @NoArgsConstructor
    @Entity
    @DynamicInsert
    public class Board {
    
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Long seq;
    
        @Column(length = 320, nullable = false)
        @ColumnDefault("'system'")
        private String usrId;
    
        public Board(String userId) {
            if (userId == null || userId.isEmpty()) {
                this.usrId = "system";
                return;
            }
            this.usrId = userId;
        }
    }