Beispiel: N:M-Relationship mit Assoziationstabelle


Inhalt:

Definition der Relation als eigene Entity
Anlegen der Session Bean "KuchenZutatAssociationTableWorkerBean"
Anlegen des Webclients
Blick in die Datenbank
Alternative: @jakarta.persistence.EmbeddedId
Ohne Annotations

Für WildFly 30 und JakartaEE 10: Dieses Beispiel erweitert das Many-To-Many-Beispiel von Kuchen und Zutat: für die Abbildung der Relation wird eine eigene Assoziationstabelle definiert, die ein zusätzliches Feld "Menge" enthält.

Hier gibt es das Projekt zum Download (dies ist ein EAR-Export, die Importanleitung findet man im Stateless-Beispiel): KuchenZutatAssociationTable.ear

Aufbau des Beispieles

a) Entity Bean für Kuchen
b) Entity Bean für Zutat
c) Entity Bean für die Verknüpfung von Kuchen und Zutat
d) Session Bean für das Arbeiten mit Zutaten und Kuchen (mit Local-Interface).
e) Webclient


Das Beispiel besteht aus einem "EAR Application Project" mit dem Namen "KuchenZutatAssociationTable", einem EJB-Projekt und einem Webprojekt.


Definition der Relation als eigene Entity

Unser Ziel ist es, statt einer ManyToMany-Assoziation eine Verknüpfungstabelle mit Feldern "KuchenID" und "ZutatID" sowie "Menge" zu erzeugen. "KuchenID" und "ZutatID" sollen den Primärschlüssel bilden. Eine besondere Schwierigkeit ergibt sich, weil der Primary Key aus zwei Relationsfeldern gebildet wird, nicht aus atomaren Datentypen.

Entity KuchenZutatAssociationTableBean: ID-Felder
Die ID-Felder der Entity sehen so aus:
import java.io.Serializable;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity()
public class KuchenZutatAssociationTableBean implements Serializable
{
  private Integer intKuchenId = null;
  private Integer intZutatId = null;
  
  @ManyToOne ()
  @Id
  @JoinColumn(name="KUCHENID"
  public KuchenAssociationTableBean getKuchen()
  {
    return this.kuchen;
  }

  public void setKuchen (KuchenAssociationTableBean kuchen)
  {
    this.kuchen = kuchen;
  }
  
  @ManyToOne ()
  @Id()
  @JoinColumn(name="ZUTATID")
  public ZutatAssociationTableBean getZutat()
  {
    return this.zutat;
  }

  public void setZutat (ZutatAssociationTableBean zutat)
  {
    this.zutat = zutat;
  }
}
Kleine Besonderheit: Es wurden explizite Spaltennamen durch eine @JoinColumn-Annotation deklariert. Dies hat rein kosmetische Gründe. Ansonsten würde WildFly hier die Spaltennamen "KUCHEN_ID" und "ZUTAT_ID" generieren - also "Name der Property", gefolgt von einem Unterstrich, und danach die Primärschlüsselspalte der referenzierten Entity, also in beiden Fällen "Id".


ID-Klasse:
Da wir einen Primary Key aus zwei Spalten haben, benötigen wir eine eigene ID-Klasse, um z.B. Instanzen der Entity über "EntityManager.getReference" zu laden (siehe KuchenZutatAssociationTableWorkerBean.removeZutatFromKuchen(Integer, Integer))
Diese ist eine simple Java-Klasse, die eine Kopie der ID-Spalten der Entity enthält.

Wichtig ist allerdings, dass hier nicht die Entity Beans als Felder auftauchen, sondern die eigentlichen Primärschlüssel dieser Entities. Hierfür ist die Regel: wenn die Entity "KuchenZutatAssociationTableBean" das ID-Feld "kuchen" (getKuchen/setKuchen) vom Typ "KuchenAssociationTableBean" hat, das eine Relation zu einer anderen Entity ist, dann muss die korrespondierende Property in der IDClass ebenfalls "kuchen" heißen und vom Typ des Schlüssels der Entität "KuchenAssociationTableBean" sein, also "Integer".
Hätte "KuchenAssociationTableBean" wiederum einen zusammengesetzten Schlüssel (und damit eine IDClass), dann müsste die Property "kuchen" in "KuchenZutatPK" vom Typ dieser IDClass sein!

Siehe
http://docs.jboss.org/hibernate/orm/4.3/manual/en-US/html/ch05.html#d5e2401 (Kapitel "5.1.2.1. Composite identifier"):
You can define a composite primary key through several syntaxes:
...
map multiple properties as @Id properties and declare an external class to be the identifier type. This class, which needs to be Serializable, is declared on the entity via the @IdClass annotation. The identifier type must contain the same properties as the identifier properties of the entity: each property name must be the same, its type must be the same as well if the entity property is of a basic type, its type must be the type of the primary key of the associated entity if the entity property is an association (either a @OneToOne or a @ManyToOne).


Eine neuere Variante der Doku findet sich hier - allerdings komplett überarbeitet und mit weniger detaillierten Beschreibungen: https://docs.jboss.org/hibernate/orm/6.4/introduction/html_single/Hibernate_Introduction.html#composite-identifiers sowie https://docs.jboss.org/hibernate/orm/6.4/userguide/html_single/Hibernate_User_Guide.html#identifiers-composite.

Hier ist der Eclipse-JPA-Validator hilfreich: er spuckt in einem solchen Fall eine (vermutlich erst einmal verwirrende) Meldung wie "There is no primary key attribute to match the ID class attribute kuchen" aus.
import java.io.Serializable;

public class KuchenZutatPK implements Serializable
{
  private static final long serialVersionUID = 1L;

  private Integer kuchen = null;
  private Integer zutat = null;

  public Integer getKuchen()
  {
    return this.kuchen;
  }
  
  public void setKuchen(Integer int_KuchenId)
  {
    this.kuchen = int_KuchenId;
  }

  public Integer getZutat()
  {
    return this.zutat;
  }

  public void setZutat(Integer int_ZutatId)
  {
    this.zutat = int_ZutatId;
  }

  @Override
  public boolean equals(Object o)
  {
    if (o == this)
    {
      return true;
    }
    if (!(o instanceof KuchenZutatPK))
    {
      return false;
    }
    KuchenZutatPK other = (KuchenZutatPK) o;
    return true && (getKuchen() == null ? other.getKuchen() == null : getKuchen().equals(other.getKuchen()))
        && (getZutat() == null ? other.getZutat() == null : getZutat().equals(other.getZutat()));
  }

  @Override
  public int hashCode()
  {
    final int prime = 31;
    int result = 1;
    result = prime * result + (getKuchen() == null ? 0 : getKuchen().hashCode());
    result = prime * result + (getZutat() == null ? 0 : getZutat().hashCode());
    return result;
  }
}
Der Code für "equals" und "hashCode" wurde mir von Eclipse generiert, da der JPA-Validator sich ansonsten beschwert hätte.

In der Entity KuchenZutatAssociationTableBean wird diese ID-Klasse deklariert.
import jakarta.persistence.IdClass;

@Entity()
@IdClass(value=KuchenZutatPK.class)
public class KuchenZutatAssociationTableBean implements Serializable
{
  ...


KuchenAssociationTableBean
In der KuchenAssociationTableBean sieht die Relation so aus:
@Entity()
public class KuchenAssociationTableBean implements Serializable
{
  ...
  private Collection<KuchenZutatAssociationTableBean> collKuchenZutaten = new ArrayList<KuchenZutatAssociationTableBean>();
  ...
  @OneToMany(mappedBy="kuchen", cascade={CascadeType.ALL}, fetch=FetchType.LAZY)
  public Collection<KuchenZutatAssociationTableBean> getZutaten()
  {
    return this.collKuchenZutaten;
  }
  
  public void setZutaten (Collection<KuchenZutatAssociationTableBean> coll_KuchenZutaten)
  {
    this.collKuchenZutaten = coll_KuchenZutaten;
  }
  ...
}
Die Entity hat eine Property "zutaten", die eine Liste von Entities KuchenZutatAssociationTableBean ist. "Cascade" steht auf CascadeType.ALL (also wird auch kaskadierend gelöscht, aber natürlich nur die Einträge in der Assoziationstabelle!), "fetch" steht wie auch im n:m-Beispiel auf "LAZY". Die @OneToMany-Relation in der Verknüpfungs-Entity kaskadiert übrigens nicht weiter zur Zutat.


ZutatAssociationTableBean
In der ZutatAssociationTableBean sieht die Relation so aus:
@Entity()
public class ZutatAssociationTableBean implements Serializable
{
  ...
  private Collection<KuchenZutatAssociationTableBean> collKuchenVerknuepfungen = new ArrayList<KuchenZutatAssociationTableBean>();
  ...
  @OneToMany(mappedBy="zutat", cascade={CascadeType.ALL}, fetch=FetchType.LAZY)
  public Collection<KuchenZutatAssociationTableBean> getKuchen()
  {
    return this.collKuchenVerknuepfungen;
  }
  
  public void setKuchen (Collection<KuchenZutatAssociationTableBean> coll_KuchenVerknuepfungen)
  {
    this.collKuchenVerknuepfungen = coll_KuchenVerknuepfungen;
  }
  ...
}



Anlegen der Session Bean "KuchenZutatAssociationTableWorkerBean"

Es wird eine SessionBean "KuchenZutatAssociationTableWorkerBean" (mit Local Interface "KuchenZutatAssociationTableWorkerLocal") zugefügt. Sie ist weitgehend identisch mit der KuchenZutatNMWorkerBean des n:m-Beispiels, nur bei der Verwaltung der Relation gibt es jetzt Unterschiede. Die Relation wird allerdings weitgehend weggekapselt, so dass Erstellen oder Löschen einer Verknüpfung (bis auf eine Ausnahme) API-kompatibel zum n:m-Beispiel ist.

Zu beachten ist, dass der Use-Case "Ändern der Menge einer bestehenden Verknüpfung" nicht umgesetzt wurde, um das Beispiel einfach zu halten!

Beim Verknüpfen eines Kuchens mit einer Zutat ist die einzige API-Änderung zu vermerken: der Parameter "menge" wird zusätzlich übergeben.
Beim Erstellen einer Verknüpfung wird eine neue Entity KuchenZutatAssociationTableBean sowie deren Primary Key KuchenZutatPK erzeugt. Auch hier gilt wie in allen bisherigen Beispielen, dass die Mapping-Entity auch der Zutatenliste des Kuchens und der Kuchenliste der Zutat zugefügt werden muss!
  public void addZutatToKuchen (Integer intKuchenId, Integer intZutatId, String strMenge)
  {
    //Kuchen und Zutat laden:
    //Es wird "getReference" verwendet, um eine Exception zu provozieren falls kein Datensatz gefunden wird.
    KuchenAssociationTableBean kuchen = this.entityManager.getReference(KuchenAssociationTableBean.class, intKuchenId );
    ZutatAssociationTableBean zutat = this.entityManager.getReference(ZutatAssociationTableBean.class, intZutatId );
    
    //Neues Mapping erzeugen:
    KuchenZutatAssociationTableBean kuchen2Zutat = new KuchenZutatAssociationTableBean();
    kuchen2Zutat.setZutat(zutat);
    kuchen2Zutat.setKuchen(kuchen);
    kuchen2Zutat.setMenge(strMenge);
    
    //Jetzt BEIDEN Seiten des Mappings zufügen!
    //Würde man das nicht tun, gäbe es zwar keinen Fehler, aber es würde auch nix
    //in die Datenbank gespeichert!
    kuchen.getZutaten().add(kuchen2Zutat);
    zutat.getKuchen().add(kuchen2Zutat);
    
    //Und eine Seite des Mappings speichern (hier dürfen wir "persist" nehmen, da alle beteiligten Entities
    //unter Kontrolle des Persistence-Managers sind, "merge" ist nicht nötig).
    this.entityManager.persist(kuchen);
  }
Das Löschen ist sogar einfacher als im letzten Beispiel geworden: anhand von Kuchen- und Zutat-ID wird eine Primary-Key-Klasse erzeugt und mit dieser die Verknüpfungs-Entity geladen (mittels "getReference", um eine Exception im nicht-gefunden-Fall zu provozieren). Anschließend wird diese Entity einfach gelöscht. Hier ist kein beiderseitiges Update der verknüpften Relationship-Seiten nötig (vermutlich, weil keine Kaskadierungen zu Kuchen/Zutat definiert sind).
 
  public void removeZutatFromKuchen (Integer intKuchenId, Integer intZutatId)
  {
    //Aus Kuchen und Zutat einen PK zusammenbasteln:
    KuchenZutatPK kuchenZutatPK = new KuchenZutatPK();
    kuchenZutatPK.setKuchen(intKuchenId);
    kuchenZutatPK.setZutat(intZutatId);
    
    //Mapping laden:
    KuchenZutatAssociationTableBean kuchen2Zutat = this.entityManager.getReference(KuchenZutatAssociationTableBean.class, kuchenZutatPK);
    
    //Killen:
    this.entityManager.remove (kuchen2Zutat);

  }



Anlegen des Webclients

Hier gibt es keine großen Unterschiede zum letzten Beispiel.

An allen Stellen, wo z.B. auf die Zutatenliste des Kuchens zugegriffen wurde, erhält man jetzt eine Liste von Verknüpfungs-Entities, diese enthalten allerdings alle eine Property, um auf die verknüpfte Zutat zuzugreifen. Hier kommt es also nur zu kleinen Änderungen.
Einzige Besonderheit ist die Seite "KuchenZutaten.jsp", auf der beim Hinzufügen einer Zutat zum Kuchen ein weiteres Eingabefeld für die Menge eingebaut wurde. Dieses enthält im Namen die Kuchen-ID ("menge_123"), um beim Hinzufügen mehrere Zutaten jeweils die Mengen eingegeben zu können.
Zu beachten ist, dass der Use-Case "Ändern der Menge einer bestehenden Verknüpfung" nicht umgesetzt wurde, um das Beispiel einfach zu halten!

Die Anwendung ist unter
http://localhost:8080/KuchenZutatAssociationTableWeb/index.jsp zu erreichen.


Blick in die Datenbank

In der Datenbank sieht das so aus (mit rotem Rahmen markiert ist die Tabelle der Relation):
Mapping-Tabelle
Im rechten Teil des Screenshots sind die Inhalte aller drei beteiligten Tabellen dargestellt.

Für die Tabelle "kuchenzutatassociationtablebean" sind außer dem Primary Key auch zwei Foreign Keys angezeigt.


Alternative: @jakarta.persistence.EmbeddedId

Eine Alternative zur "IDClass" wäre eine @jakarta.persistence.EmbeddedId. Eigentlich ist der Vorteil davon, dass die ID-Kombination nicht mehr doppelt gehalten werden muss (einmal in der Entity selbst, und außerdem in abgewandelter Form in der IDClass), sondern nur noch in einer einzigen Klasse steckt, die wiederum direkt als Primärschlüsselfeld verwendet wird.
Das klappt allerdings nur, wenn die ID-Spalten allesamt "einfache" Felder sind. Relationsfelder dürfen nicht in einer "EmbeddedId"-Klasse vorkommen.

Zitat aus der JPA-3.1-Spezifikation:
11.1.17. EmbeddedId Annotation
The EmbeddedId annotation is applied to a persistent field or property of an entity class or mapped superclass to denote a composite primary key that is an embeddable class. The embeddable class must be annotated as Embeddable. Relationship mappings defined within an embedded id class are not supported.


Hibernate würde diese Funktion sogar unterstützen, aber dann wäre das Beispiel nicht JPA-kompatibel.

Deshalb müssen wir für dieses Beispiel einen Schritt zurückgehen, so wie es ursprünglich in der Variante für JavaEE5 aussah (bevor Relationsfelder als Schlüsselspalten zugelassen waren): in der Mapping-Klasse "KuchenZutatAssociationTableBean" gibt es eine Property für Kuchen und Zutat, jeweils mit den Relationsfeldern, sowie eine Instanz der Primärschlüsselklasse, die wiederum KuchenId und ZutatId enthält.

Siehe auch http://stackoverflow.com/questions/10078224/foreign-key-mapping-inside-embeddable-class
Die modifizierte Version des Projekts gibt es hier: KuchenZutatAssociationTableEmbeddedId.ear.
ACHTUNG: Dieses Projekt kann nicht neben dem obigen KuchenZutatAssociationTable-Beispiel existieren !

Ohne Annotations

In "ejb-jar.xml" gibt es keine Neuerungen im Vergleich zum KuchenZutatNM-Beispiel.

"orm.xml" enthält die Angaben über das Mapping:
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_1.xsd"
	version="3.1">
	<named-query name="findAllKuchen">
		<query>select o from KuchenAssociationTableBean o</query>
	</named-query>
	<named-query name="findAllZutaten">
		<query>select o from ZutatAssociationTableBean o</query>
	</named-query>

	<entity class="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.KuchenAssociationTableBean" access="PROPERTY"
		metadata-complete="true">
		<attributes>
			<id name="id">
				<generated-value />
			</id>
			<basic name="name">
			</basic>
			<one-to-many name="zutaten" mapped-by="kuchen" fetch="LAZY"
				target-entity="de.hsrm.jakartaee.knauf.knauf.kuchenzutatassociationtable.KuchenZutatAssociationTableBean">
				<cascade>
					<cascade-all/>
				</cascade>
			</one-to-many>
		</attributes>
	</entity>

	<entity class="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.ZutatAssociationTableBean" access="PROPERTY"
		metadata-complete="true">
		<attributes>
			<id name="id">
				<generated-value />
			</id>
			<basic name="zutatName">
			</basic>
			<one-to-many name="kuchen" mapped-by="zutat" fetch="LAZY"
				target-entity="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.KuchenZutatAssociationTableBean">
				<cascade>
					<cascade-all/>
				</cascade>
			</one-to-many>
		</attributes>
	</entity>
	
	<entity class="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.KuchenZutatAssociationTableBean" access="PROPERTY"
		metadata-complete="true">
		<id-class class="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.KuchenZutatPK"/>
		<attributes>
			<basic name="menge">
			</basic>
			
			<many-to-one name="kuchen" id="true"
				target-entity="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.KuchenAssociationTableBean">
				
				<join-column name="KUCHENID"/>
			</many-to-one>
			
			<many-to-one name="zutat" id="true"
				target-entity="de.hsrm.jakartaee.knauf.kuchenzutatassociationtable.ZutatAssociationTableBean">
				<join-column name="ZUTATID" />
			</many-to-one>
		</attributes>
	</entity>
</entity-mappings>
Neue Elemente in diesem Beispiel sind in der Deklaration der KuchenZutatAssociationTableBean zu finden:
Die modifizierte Version des Projekts gibt es hier:
KuchenZutatAssociationTableNoAnnotation.ear.
ACHTUNG: Dieses Projekt kann nicht neben dem obigen KuchenZutatAssociationTable-Beispiel existieren !



Stand 04.02.2024
Historie:
04.02.2024: Aus JBoss7-Beispiel erstellt, Anpassungen an WildFly 30 und JakartaEE10