diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/cascade/circle/CascadeManagedAndTransientTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/cascade/circle/CascadeManagedAndTransientTest.java new file mode 100644 index 000000000000..710eb0ae0151 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/cascade/circle/CascadeManagedAndTransientTest.java @@ -0,0 +1,584 @@ +/* + * SPDX-License-Identifier: LGPL-2.1-or-later + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.cascade.circle; + +import jakarta.persistence.Basic; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import jakarta.persistence.Version; +import org.hibernate.cfg.Environment; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.JiraKey; +import org.hibernate.testing.orm.junit.ServiceRegistry; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.hibernate.testing.orm.junit.Setting; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The test case uses the following model: + *

+ * <- -> + * -- (N : 0,1) -- Tour + * | <- -> + * | -- (1 : N) -- (pickup) ---- + * <- -> | | | + * Route -- (1 : N) -- Node Transport + * | <- -> | + * -- (1 : N) -- (delivery) -- + *

+ * Arrows indicate the direction of cascade-merge, cascade-persist, cascade-refresh + *

+ * It reproduces the following issues: + * https://hibernate.atlassian.net/browse/HHH-9512 + *

+ * This tests that cascades are done properly from each entity. + * + * @author Alex Belyaev (based on code by Pavol Zibrita and Gail Badner) + */ +@DomainModel( + annotatedClasses = { + CascadeManagedAndTransientTest.Node.class, + CascadeManagedAndTransientTest.Route.class, + CascadeManagedAndTransientTest.Tour.class, + CascadeManagedAndTransientTest.Transport.class, + CascadeManagedAndTransientTest.Vehicle.class + } +) +@SessionFactory +@ServiceRegistry( + settings = @Setting(name = Environment.CHECK_NULLABILITY, value = "true") +) +@JiraKey("HHH-9512") +public class CascadeManagedAndTransientTest { + + @AfterEach + public void cleanupTest(SessionFactoryScope scope) { + scope.inTransaction( + session -> { + session.createMutationQuery( "delete from Transport" ); + session.createMutationQuery( "delete from Tour" ); + session.createMutationQuery( "delete from Node" ); + session.createMutationQuery( "delete from Route" ); + session.createMutationQuery( "delete from Vehicle" ); + } + ); + } + + @Test + public void testAttachedChildInMerge(SessionFactoryScope scope) { + fillInitialData( scope ); + + scope.inTransaction( + session -> { + Route route = session.createQuery( "FROM Route WHERE name = :name", Route.class ) + .setParameter( "name", "Route 1" ) + .uniqueResult(); + Node n2 = session.createQuery( "FROM Node WHERE name = :name", Node.class ) + .setParameter( "name", "Node 2" ) + .uniqueResult(); + Node n3 = session.createQuery( "FROM Node WHERE name = :name", Node.class ) + .setParameter( "name", "Node 3" ) + .uniqueResult(); + + Vehicle vehicle = new Vehicle(); + vehicle.setName( "Bus" ); + vehicle.setRoute( route ); + + Transport $2to3 = new Transport(); + $2to3.setName( "Transport 2 -> 3" ); + $2to3.setPickupNode( n2 ); + n2.getPickupTransports().add( $2to3 ); + $2to3.setDeliveryNode( n3 ); + n3.getDeliveryTransports().add( $2to3 ); + $2to3.setVehicle( vehicle ); + + vehicle.setTransports( Set.of( $2to3 ) ); + + // Try to save graph of transient entities (vehicle, transport) which contains attached entities (node2, node3) + Vehicle managedVehicle = (Vehicle) session.merge( vehicle ); + checkNewVehicle( managedVehicle ); + + session.flush(); + session.clear(); + + assertEquals( 3, session.createQuery( "FROM Transport", Transport.class ).list().size() ); + assertEquals( 2, session.createQuery( "FROM Vehicle", Vehicle.class ).list().size() ); + assertEquals( 4, session.createQuery( "FROM Node", Node.class ).list().size() ); + + Vehicle newVehicle = session.createQuery( "FROM Vehicle WHERE name = :name", Vehicle.class ) + .setParameter( "name", "Bus" ) + .uniqueResult(); + checkNewVehicle( newVehicle ); + } + ); + } + + private void checkNewVehicle(Vehicle newVehicle) { + assertEquals( "Bus", newVehicle.getName() ); + assertEquals( 1, newVehicle.getTransports().size() ); + Transport t = (Transport) newVehicle.getTransports().iterator().next(); + assertEquals( "Transport 2 -> 3", t.getName() ); + assertEquals( "Node 2", t.getPickupNode().getName() ); + assertEquals( "Node 3", t.getDeliveryNode().getName() ); + } + + private void fillInitialData(SessionFactoryScope scope) { + Tour tour = new Tour(); + tour.setName( "Tour 1" ); + + Route route = new Route(); + route.setName( "Route 1" ); + + ArrayList nodes = new ArrayList(); + for ( int i = 0; i < 4; i++ ) { + Node n = new Node(); + n.setName( "Node " + i ); + n.setTour( tour ); + n.setRoute( route ); + nodes.add( n ); + } + + tour.setNodes( new HashSet<>( nodes ) ); + route.setNodes( new HashSet<>( Arrays.asList( nodes.get( 0 ), nodes.get( 1 ), nodes.get( 2 ) ) ) ); + + Vehicle vehicle = new Vehicle(); + vehicle.setName( "Car" ); + route.setVehicles( Set.of( vehicle ) ); + vehicle.setRoute( route ); + + Transport $0to1 = new Transport(); + $0to1.setName( "Transport 0 -> 1" ); + $0to1.setPickupNode( nodes.get( 0 ) ); + $0to1.setDeliveryNode( nodes.get( 1 ) ); + $0to1.setVehicle( vehicle ); + + Transport $1to2 = new Transport(); + $1to2.setName( "Transport 1 -> 2" ); + $1to2.setPickupNode( nodes.get( 1 ) ); + $1to2.setDeliveryNode( nodes.get( 2 ) ); + $1to2.setVehicle( vehicle ); + + vehicle.setTransports( new HashSet<>( Arrays.asList( $0to1, $1to2 ) ) ); + + scope.inTransaction( + session -> + session.persist( tour ) + ); + } + + @Entity(name = "Route") + @Table(name = "HB_Route") + public static class Route { + + @Id + @GeneratedValue + private Long routeID; + + private long version; + + @OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, mappedBy = "route") + private Set nodes = new HashSet<>(); + + @OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, mappedBy = "route") + private Set vehicles = new HashSet<>(); + + @Basic(optional = false) + private String name; + + @Transient + private String transientField = null; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Set getNodes() { + return nodes; + } + + protected void setNodes(Set nodes) { + this.nodes = nodes; + } + + protected Set getVehicles() { + return vehicles; + } + + protected void setVehicles(Set vehicles) { + this.vehicles = vehicles; + } + + protected void setRouteID(Long routeID) { + this.routeID = routeID; + } + + public Long getRouteID() { + return routeID; + } + + public long getVersion() { + return version; + } + + protected void setVersion(long version) { + this.version = version; + } + + public String getTransientField() { + return transientField; + } + + public void setTransientField(String transientField) { + this.transientField = transientField; + } + } + + @Entity(name = "Tour") + @Table(name = "HB_Tour") + public static class Tour { + + @Id + @GeneratedValue + private Long tourID; + + @Version + private long version; + + @Basic(optional = false) + private String name; + + @OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, mappedBy = "tour") + private Set nodes = new HashSet<>( 0 ); + + public String getName() { + return name; + } + + protected void setTourID(Long tourID) { + this.tourID = tourID; + } + + public long getVersion() { + return version; + } + + protected void setVersion(long version) { + this.version = version; + } + + public void setName(String name) { + this.name = name; + } + + public Set getNodes() { + return nodes; + } + + public void setNodes(Set nodes) { + this.nodes = nodes; + } + + public Long getTourID() { + return tourID; + } + } + + @Entity(name = "Transport") + @Table(name = "HB_Transport") + public static class Transport { + + @Id + @GeneratedValue + private Long transportID; + + @Version + private long version; + + @Basic(optional = false) + private String name; + + @ManyToOne( + optional = false, + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER) + @JoinColumn(name = "pickupNodeID", nullable = false) + private Node pickupNode = null; + + @ManyToOne( + optional = false, + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER) + @JoinColumn(name = "deliveryNodeID", nullable = false) + private Node deliveryNode = null; + + @ManyToOne( + optional = false, + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER + ) + private Vehicle vehicle; + + @Transient + private String transientField = "transport original value"; + + public Node getDeliveryNode() { + return deliveryNode; + } + + public void setDeliveryNode(Node deliveryNode) { + this.deliveryNode = deliveryNode; + } + + public Node getPickupNode() { + return pickupNode; + } + + protected void setTransportID(Long transportID) { + this.transportID = transportID; + } + + public void setPickupNode(Node pickupNode) { + this.pickupNode = pickupNode; + } + + public Vehicle getVehicle() { + return vehicle; + } + + public void setVehicle(Vehicle vehicle) { + this.vehicle = vehicle; + } + + public Long getTransportID() { + return transportID; + } + + public long getVersion() { + return version; + } + + protected void setVersion(long version) { + this.version = version; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTransientField() { + return transientField; + } + + public void setTransientField(String transientField) { + this.transientField = transientField; + } + } + + @Entity(name = "Vehicle") + @Table(name = "HB_Vehicle") + public static class Vehicle { + + @Id + @GeneratedValue + private Long vehicleID; + + @Version + private long version; + + @Basic(optional = false) + private String name; + + @OneToMany( + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER + ) + private Set transports = new HashSet<>(); + + @ManyToOne( + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER + ) + private Route route; + + @Transient + private String transientField = "vehicle original value"; + + protected void setVehicleID(Long vehicleID) { + this.vehicleID = vehicleID; + } + + public Long getVehicleID() { + return vehicleID; + } + + public long getVersion() { + return version; + } + + protected void setVersion(long version) { + this.version = version; + } + + public Set getTransports() { + return transports; + } + + public void setTransports(Set transports) { + this.transports = transports; + } + + public Route getRoute() { + return route; + } + + public void setRoute(Route route) { + this.route = route; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTransientField() { + return transientField; + } + + public void setTransientField(String transientField) { + this.transientField = transientField; + } + } + + @Entity(name = "Node") + @Table(name = "HB_Node") + public static class Node { + + @Id + @GeneratedValue + private Long nodeID; + + @Version + private long version; + + @Basic(optional = false) + private String name; + + @OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, mappedBy = "deliveryNode") + private Set deliveryTransports = new HashSet<>(); + + @OneToMany(cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, mappedBy = "pickupNode") + private Set pickupTransports = new HashSet<>(); + + @ManyToOne( + optional = false, + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER + ) + private Route route = null; + + @ManyToOne( + optional = false, + cascade = {CascadeType.MERGE, CascadeType.PERSIST, CascadeType.REFRESH}, + fetch = FetchType.EAGER + ) + private Tour tour; + + @Transient + private String transientField = "node original value"; + + public Set getDeliveryTransports() { + return deliveryTransports; + } + + public void setDeliveryTransports(Set deliveryTransports) { + this.deliveryTransports = deliveryTransports; + } + + public Set getPickupTransports() { + return pickupTransports; + } + + public void setPickupTransports(Set pickupTransports) { + this.pickupTransports = pickupTransports; + } + + public Long getNodeID() { + return nodeID; + } + + public long getVersion() { + return version; + } + + protected void setVersion(long version) { + this.version = version; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Route getRoute() { + return route; + } + + public void setRoute(Route route) { + this.route = route; + } + + public Tour getTour() { + return tour; + } + + public void setTour(Tour tour) { + this.tour = tour; + } + + public String getTransientField() { + return transientField; + } + + public void setTransientField(String transientField) { + this.transientField = transientField; + } + + protected void setNodeID(Long nodeID) { + this.nodeID = nodeID; + } + + } +}