如何使用 Spring 数据 Rest 修补项目资源的无存储库集合

How to PATCH repositoryless collections of item resources with Spring Data Rest

简短:是否可以编辑项目资源的值实体集合(“替换集合”)?

假设有以下模型:

+---------+            +------------------+            +--------+
| Student | <-1----n-> | CourseMembership | <-m----1-> | Course |
+---------+            +------------------+            +--------+

StudentCourse 通过 Spring Data Rest 和相应的存储库导出(StudentsCourses,两者都是 JpaRepository)存在。 CourseMembership 不应导出(不应该存在它的端点)因此,相应的存储库也没有 exist/is 没有必要。

下面是三个类为三个entities/tables:

Entity
@Table(name = "students")
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class Course extends AbstractPersistable<Long> {
    
    @Column(name = "name", nullable = false)
    private String name;

}
@Entity
@Table(name = "course_memberships")
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class CourseMembership extends AbstractPersistable<Long> {

  @ManyToOne
  @JoinColumn(name = "student")
  private Student student;
  
  @ManyToOne
  @JoinColumn(name = "course")
  private Course course;
  
}
@Entity
@Table(name = "students")
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class Student extends AbstractPersistable<Long> {

  @Column(name = "name", nullable = false)
  private String name;

  @OneToMany(mappedBy = "student", cascade = { CascadeType.ALL })
  // @OneToMany(mappedBy = "student", cascade = { CascadeType.ALL }, orphanRemoval = true)
  @Setter(AccessLevel.NONE)
  private List<CourseMembership> courseMemberships = new ArrayList<>();

  public void setCourseMemberships(List<CourseMembership> courseMemberships) {
    this.courseMemberships.clear();
    this.courseMemberships.addAll(courseMemberships);
    this.courseMemberships.forEach(courseMembership -> courseMembership.setStudent(this));
  }

}

第一次修补课程成员时,一切正常,插入元素:

~$  curl -X PATCH -H "Content-Type: application/json" "http://localhost:8080/students/3" -d '{"courseMemberships":[{"course":"/courses/1"},{"course":"/courses/2"}]}'
{
  "name" : "John Doe",
  "courseMemberships" : [ {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/1"
      }
    }
  }, {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/2"
      }
    }
  } ],
  "new" : false,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/students/3"
    },
    "student" : {
      "href" : "http://localhost:8080/students/3"
    }
  }
}

但是在接下来的 PATCH 操作中,我很挣扎:

Problem/Question:

当我尝试替换列表时(例如,用一个空列表或另一个包含其他元素的列表),要么没有任何反应(没有 orphanRemoval = true),要么发生异常:

没有orphanRemoval = true:

在 PATCH 响应中,集合看起来已修改,但在 PATCH 之后使用 GET,响应显示原始集合。

(推定:有两个元素)

~$  curl  -X PATCH -H "Content-Type: application/json" "http://localhost:8080/students/3" -d '{"courseMemberships":[]}'
{
  "name" : "John Doe",
  "courseMemberships" : [ ],
  "new" : false,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/students/3"
    },
    "student" : {
      "href" : "http://localhost:8080/students/3"
    }
  }
}

~$ curl -X GET "http://localhost:8080/students/3"
{
  "name" : "John Doe",
  "courseMemberships" : [ {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/1"
      }
    }
  }, {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/2"
      }
    }
  } ],
  "new" : false,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/students/3"
    },
    "student" : {
      "href" : "http://localhost:8080/students/3"
    }
  }
}

orphanRemoval = true:

可以清除现有列表,但“用另一个列表替换”会导致异常。

(推定:只有一个元素)

~$ curl  -X PATCH -H "Content-Type: application/json" "http://localhost:8080/students/3" -d '{"courseMemberships":[{"course":"/courses/1"},{"course":"/courses/2"}]}'
{"cause":{"cause":null,"message":"not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student"},"message":"not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student; nested exception is org.hibernate.PropertyValueException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student"}

2021-05-01 20:15:55.344 ERROR 23606 --- [nio-8080-exec-4] o.s.d.r.w.RepositoryRestExceptionHandler : not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student; nested exception is org.hibernate.PropertyValueException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student

org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student; nested exception is org.hibernate.PropertyValueException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:294) ~[spring-orm-5.3.6.jar:5.3.6]
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233) ~[spring-orm-5.3.6.jar:5.3.6]
    at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:551) ~[spring-orm-5.3.6.jar:5.3.6]
    at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:242) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:174) ~[spring-data-jpa-2.4.8.jar:2.4.8]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215) ~[spring-aop-5.3.6.jar:5.3.6]
    at com.sun.proxy.$Proxy97.save(Unknown Source) ~[na:na]
    at org.springframework.data.repository.support.CrudRepositoryInvoker.invokeSave(CrudRepositoryInvoker.java:101) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.rest.core.support.UnwrappingRepositoryInvokerFactory$UnwrappingRepositoryInvoker.invokeSave(UnwrappingRepositoryInvokerFactory.java:181) ~[spring-data-rest-core-3.4.8.jar:3.4.8]
    at org.springframework.data.rest.webmvc.RepositoryEntityController.saveAndReturn(RepositoryEntityController.java:446) ~[spring-data-rest-webmvc-3.4.8.jar:3.4.8]
    at org.springframework.data.rest.webmvc.RepositoryEntityController.patchItemResource(RepositoryEntityController.java:395) ~[spring-data-rest-webmvc-3.4.8.jar:3.4.8]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:197) ~[spring-web-5.3.6.jar:5.3.6]
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:141) ~[spring-web-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:106) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:894) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1060) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:962) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:880) ~[spring-webmvc-5.3.6.jar:5.3.6]
    at javax.servlet.http.HttpServlet.service(HttpServlet.java:733) ~[tomcat-embed-core-9.0.45.jar:4.0.FR]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.6.jar:5.3.6]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.6.jar:5.3.6]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.6.jar:5.3.6]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.6.jar:5.3.6]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.6.jar:5.3.6]
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.6.jar:5.3.6]
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:374) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1707) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) ~[na:na]
    at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) ~[na:na]
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[tomcat-embed-core-9.0.45.jar:9.0.45]
    at java.base/java.lang.Thread.run(Thread.java:829) ~[na:na]
Caused by: org.hibernate.PropertyValueException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student
    at org.hibernate.engine.internal.Nullability.checkNullability(Nullability.java:111) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Nullability.checkNullability(Nullability.java:55) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.action.internal.AbstractEntityInsertAction.nullifyTransientReferencesIfNotAlready(AbstractEntityInsertAction.java:116) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.action.internal.AbstractEntityInsertAction.makeEntityManaged(AbstractEntityInsertAction.java:125) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.spi.ActionQueue.addResolvedEntityInsertAction(ActionQueue.java:289) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.spi.ActionQueue.addInsertAction(ActionQueue.java:263) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.spi.ActionQueue.addAction(ActionQueue.java:250) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.AbstractSaveEventListener.addInsertAction(AbstractSaveEventListener.java:338) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.AbstractSaveEventListener.performSaveOrReplicate(AbstractSaveEventListener.java:287) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:193) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.AbstractSaveEventListener.saveWithGeneratedId(AbstractSaveEventListener.java:135) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.saveTransientEntity(DefaultMergeEventListener.java:271) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.entityIsTransient(DefaultMergeEventListener.java:243) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:175) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:104) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:813) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:786) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.spi.CascadingActions.cascade(CascadingActions.java:261) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeToOne(Cascade.java:499) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeAssociation(Cascade.java:423) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeProperty(Cascade.java:220) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeCollectionElements(Cascade.java:532) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeCollection(Cascade.java:463) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeAssociation(Cascade.java:426) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascadeProperty(Cascade.java:220) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.engine.internal.Cascade.cascade(Cascade.java:153) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.cascadeOnMerge(DefaultMergeEventListener.java:519) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.entityIsPersistent(DefaultMergeEventListener.java:204) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:178) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:70) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:93) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:793) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:780) ~[hibernate-core-5.4.30.Final.jar:5.4.30.Final]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
    at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:362) ~[spring-orm-5.3.6.jar:5.3.6]
    at com.sun.proxy.$Proxy90.merge(Unknown Source) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
    at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:311) ~[spring-orm-5.3.6.jar:5.3.6]
    at com.sun.proxy.$Proxy90.merge(Unknown Source) ~[na:na]
    at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:560) ~[spring-data-jpa-2.4.8.jar:2.4.8]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new[=17=](RepositoryMethodInvoker.java:289) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:137) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:121) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:524) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:285) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:531) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:156) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:131) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:80) ~[spring-data-commons-2.4.8.jar:2.4.8]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.transaction.interceptor.TransactionInterceptor.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:388) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-5.3.6.jar:5.3.6]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.3.6.jar:5.3.6]
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:137) ~[spring-tx-5.3.6.jar:5.3.6]
    ... 59 common frames omitted

2021-05-01 20:15:55.350  WARN 23606 --- [nio-8080-exec-4] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student; nested exception is org.hibernate.PropertyValueException: not-null property references a null or transient value : io.gitlab.hjoeren.foo.CourseMembership.student]


可能是一个有用的注释:当集合中最初只有一个元素,并且 PATCH 的集合由另一个元素组成时,该元素将被更新 - 而不是删除和插入:

(推定:之前没有 courseMemberships)

~$ curl -X PATCH -H "Content-Type: application/json" "http://localhost:8080/students/3" -d '{"courseMemberships":[{"course":"/courses/1"}]}'
{
  "name" : "John Doe",
  "courseMemberships" : [ {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/1"
      }
    }
  } ],
  "new" : false,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/students/3"
    },
    "student" : {
      "href" : "http://localhost:8080/students/3"
    }
  }
}

Hibernate: select student0_.id as id1_2_0_, student0_.name as name2_2_0_ from students student0_ where student0_.id=?
Hibernate: select coursememb0_.student as student3_0_0_, coursememb0_.id as id1_0_0_, coursememb0_.id as id1_0_1_, coursememb0_.course as course2_0_1_, coursememb0_.student as student3_0_1_, course1_.id as id1_1_2_, course1_.name as name2_1_2_ from course_memberships coursememb0_ inner join courses course1_ on coursememb0_.course=course1_.id where coursememb0_.student=?
Hibernate: select course0_.id as id1_1_0_, course0_.name as name2_1_0_ from courses course0_ where course0_.id=?
Hibernate: call next value for hibernate_sequence
Hibernate: insert into course_memberships (course, student, id) values (?, ?, ?)

~$ curl  -X PATCH -H "Content-Type: application/json" "http://localhost:8080/students/3" -d '{"courseMemberships":[{"course":"/courses/2"}]}'
{
  "name" : "John Doe",
  "courseMemberships" : [ {
    "new" : false,
    "_links" : {
      "student" : {
        "href" : "http://localhost:8080/students/3"
      },
      "course" : {
        "href" : "http://localhost:8080/courses/2"
      }
    }
  } ],
  "new" : false,
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/students/3"
    },
    "student" : {
      "href" : "http://localhost:8080/students/3"
    }
  }
}

Hibernate: select student0_.id as id1_2_0_, student0_.name as name2_2_0_ from students student0_ where student0_.id=?
Hibernate: select coursememb0_.student as student3_0_0_, coursememb0_.id as id1_0_0_, coursememb0_.id as id1_0_1_, coursememb0_.course as course2_0_1_, coursememb0_.student as student3_0_1_, course1_.id as id1_1_2_, course1_.name as name2_1_2_ from course_memberships coursememb0_ inner join courses course1_ on coursememb0_.course=course1_.id where coursememb0_.student=?
Hibernate: select course0_.id as id1_1_0_, course0_.name as name2_1_0_ from courses course0_ where course0_.id=?
Hibernate: update course_memberships set course=?, student=? where id=?

由于无论如何都没有计划存储库,因此可以将 CourseMembership@Entity 更改为 @Embeddable 并将 Student 中的关联从 @OneToMany@ElementCollection:

@Embeddable
@Getter
@Setter
public class CourseMembership {

    @ManyToOne
    @JoinColumn(name = "course", nullable = false)
    private Course course;

}
@Entity
@Table(name = "students")
@Getter
@Setter
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class Student extends AbstractPersistable<Long> implements Serializable {

    private static final long serialVersionUID = 2741464453152791261L;

    @Column(name = "name", nullable = false)
    private String name;

    @ElementCollection
    @CollectionTable(name = "course_memberships", joinColumns = { @JoinColumn(name = "student") })
    private List<CourseMembership> courseMemberships = new ArrayList<>();

}

鉴于此,问题的 PATCH 操作按预期工作,而且我还认为,查看模型这将是更简洁的方法(álá“CourseMembership as @Embeddable more 表示有是一个组合,没有 Student")

是无用的