0

Spring Boot/Hibernate/JPA/MySQL here. I have the following two JPA entities:

@MappedSuperclass
public abstract class BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String refId;
}

@MappedSuperclass
public abstract class BaseLookup extends BaseEntity {
    @JsonIgnore
    @NotNull
    private String name;

    @NotNull
    private String label;

    @JsonIgnore
    @NotNull
    private String description;
}

@Entity
@Table(name = "device_systems")
@AttributeOverrides({
        @AttributeOverride(name = "id", column=@Column(name="device_system_id")),
        @AttributeOverride(name = "refId", column=@Column(name="device_system_ref_id")),
        @AttributeOverride(name = "name", column=@Column(name="device_system_name")),
        @AttributeOverride(name = "label", column=@Column(name="device_system_label")),
        @AttributeOverride(name = "description", column=@Column(name="device_system_description"))
})
public class DeviceSystem extends BaseLookup {
}

@Entity
@Table(name = "devices")
@AttributeOverrides({
        @AttributeOverride(name = "id", column=@Column(name="device_id")),
        @AttributeOverride(name = "refId", column=@Column(name="device_ref_id"))
})
@JsonDeserialize(using = DeviceDeserializer.class)
class Device extends BaseEntity {
    @Column(name = "device_app_version")
    private String appVersion;

    @OneToOne(fetch = FetchType.EAGER, cascade = [CascadeType.PERSIST, CascadeType.MERGE])
    @JoinColumn(name = "device_system_id", referencedColumnName = "device_system_id")
    @NotNull
    @Valid
    private DeviceSystem system;

    @Column(name = "device_system_version")
    private String systemVersion;

    @Column(name = "device_model")
    private String model;
}

And the following to CrudRepository"s for them:

public interface DevicePersistor extends CrudRepository<Device,Long> {
}

public interface DeviceSystemPersistor extends CrudRepository<DeviceSystem,Long> {
    @Query("FROM DeviceSystem WHERE label = 'ANDROID'")
    public DeviceSystem android();

    @Query("FROM DeviceSystem WHERE label = 'iOS'")
    public DeviceSystem iOS();

    @Query("FROM DeviceSystem WHERE name = :name")
    public DeviceSystem findByName(@Param(value = "name") String name);
}

Here"s the results when I run select * from device_systems; (this is MySQL):

mysql> select * from device_systems;
+------------------+--------------------------------------+--------------------+---------------------+-------------------------------+
| device_system_id | device_system_ref_id                 | device_system_name | device_system_label | device_system_description     |
+------------------+--------------------------------------+--------------------+---------------------+-------------------------------+
|                1 | 5e2bc70b-d570-43c7-b420-9add794f7c76 | Android            | ANDROID             | Google/Android based devices. |
|                2 | 312d82fa-b0db-4c9a-a356-4e2610373f3f | iOS                | iOS                 | Apple/iOS based devices.      |

The device_systems table contains static/lookup/reference data, and so there should be two and only two (ever) records in that table. In my app, a user can create a new device with the following curl command:


curl -k -i -H "Content-Type: application/json" -X POST \
    -d '{ "appVersion" : "0.0.1", "system" : "Android", "systemVersion" : "7.0", "model" : "Samsung Galaxy S7 Edge" }' \ 
   https://localhost:9200/v1/devices

The DeviceDeserializer looks like:

public class DeviceDeserializer extends JsonDeserializer<Device> {
    @Autowired
    private DeviceSystemPersistor deviceSystemPersistor;

    @Override
    public Device deserialize(JsonParser jsonParser, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        JsonNode deviceNode = jsonParser.readValueAsTree();

        String appVersion = deviceNode.get("appVersion").asText();

        // Lookup the DeviceSystem record/entity/instance by the name provided in the JSON.
        // This is because the user can only specify 'Android' or 'iOS'; no other values allowed!
        DeviceSystem deviceSystem = deviceSystemPersistor.findByName(deviceNode.get("system").asText());

        String systemVersion = deviceNode.get("systemVersion").asText();
        String model = deviceNode.get("model").asText();

        return new Device(appVersion, deviceSystem, systemVersion, model);
    }
}

And the DeviceController method that handles that POST looks like:

@PostMapping
public void saveDevice(@RequestBody Device device) {
    devicePersistor.save(device);
}

At runtime, when I run that curl command, I get the following exception:

nested exception is org.hibernate.PersistentObjectException: detached entity passed to persist: com.myapp.entities.DeviceSystem
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:299)
    at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
    at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:488)
    at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:59)
    at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:213)
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:147)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:133)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:92)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)

The stacktrace is huge but indicates the devicePersistor.save(device) call as the source of the PersistentObjectException...

It sounds like JPA/Hibernate doesn"t like the fact that I"m trying to persist a Device that has an existing DeviceSystem associated with it. To be clear, I"m not trying to create a new DeviceSystem instance, I"m just trying to save my new Device instance to be associated with an existing DeviceSystem. How do I do this the correct "JPA" way?

smeeb
  • 27,777
  • 57
  • 250
  • 447

1 Answers1

1

First of all, remove CascadeType.PERSIST. You don't want to persist a pre-existing, detached entity. I'd also remove CascadeType.MERGE since you say DeviceSystem represents read-only data. Cascading operations is rarely a good idea with many-to-one associations (as a side note, the @OneToOne should really be a @ManyToOne).

The problem here is that Spring Data detects Device to be a new entity and decides to call EntityManager.persist() rather than EntityManager.merge(). This means that the PERSIST operation is cascaded to Device.system, and since at this stage Device.system points to a detached entity, an exception is thrown.

Even after you disable the cascade, you'll probably keep getting an exception, since Device.system is still referencing a detached entity.

The safest solution is to replace the value of Device.system with a reference to a managed entity like so:

@Transactional
public Device saveDevice(Device device) {
    device.setSystem(em.getReference(DeviceSystem.class, device.getSystem().getId()));
    if (device.getId() == null) {
        em.persist(device);
        return device;
    } else {
        return em.merge(device);
    }
}

You could add the above method to the repository/service, or override the default CrudRepository.save() method - refer to Spring Data: Override save method for ways of customizing Spring-generated repository methods.

An alternative approach would be to leave CascadeType.MERGE and make Device implement Persistable in such a way that isNew() always returns false (so that EntityManager.merge() always gets called), but that's a rather hacky workaround. Besides, you could accidentally override the state of DeviceSystem.

crizzis
  • 9,978
  • 2
  • 28
  • 47
  • Thanks @crizzis (+1) - but just curious, why do you think `Device` needs a `@ManyToOne` relationship with `DeviceSystem`? Every device has 1-and-only-1 device system (the deivce is *either* and Android or iOS phone, never both!). – smeeb Jan 26 '18 at 16:20
  • I don't think I follow. Every `Device` has one `DeviceSystem`. At the same time, a single `DeviceSystem` can be assigned to multiple devices. That's a many-to-one association, isn't it? – crizzis Jan 26 '18 at 16:22
  • Thanks again @crizzis, but nope, thats not the data model I need/want, unless you're telling me I *have* to use it for some strange reason. A `Device` is a phone. A `DeviceSystem` is a table that stores all the different available operating systems for phones. Currently my app only supports 2 possible systems: Android and iOS. Hence there are only 2 records in the `device_systems` table. And `Devices` (phones) that are Android will be associated to the *same* Android `DeviceSystem` record; ditto for iOS, make sense? – smeeb Jan 26 '18 at 17:51
  • 1
    That's exactly what I meant: '(**many**) devices will be associated to the same (**one**) android device system'. – crizzis Jan 26 '18 at 18:50