Skip to content

JPA 1. rész – Entitások

Zalan Toth edited this page Feb 17, 2018 · 1 revision

A Java Persistance API egy interface specifikáció, mely leírja az adatok menedzselését Java objektumok és egy relációs adatbázis között. Többféle megvalósítása létezik, a legnépszerűbbek a Hibernate és az EclipseLink. Az ilyen keretrendszereket röviden ORM-nek (Object to Relational Mapping) nevezzük. JPA használatánál Entitásokat hozunk létre, ezek annotált Java osztályok, melyek az adatbázis tábláit képezik le. Az ORM-eket számos kritika éri, többek között komplexitásuk, nehéz tanulhatóságuk, valamint az ORM-et használó rendszerek performancia problémái miatt, mely bár ténylegesen lassabb mint egy natív SQL, de mégis, a sebességproblémák fő oka inkább keresendő a rosszul megtervezett rendszerben mint az ORM-ekben.

A cikksorozatban a Hibernate implementáció segítségével mutatom be a JPA alapjait, a komplex alkalmazás konfiguráció elkerülése érdekében Spring Boottal együtt. Az adatbázis verziózása és az adatmigrálás Liquibase segítségével történik. A JPA saját lekérdező nyelve a JPQL (mely nagyban hasonlít az SQL-hez) és a Criteria API (mellyel dinamikusan állíthatunk össze lekérdezéseket) mellett a Spring Data JPA-t (interfacekből generált lekérdezések) és a JINQ keretrendszert (natural Java lekérdezések funkcionális stílusban) is kipróbáljuk.

A JPA entitások az adatbázis táblák leképezései Java osztályokká.

Az EntityManager kezeli az entitások életciklusát a Persistance Context ben.

Ezek a következők lehetnek:

New/Transient: új entitás, még nincs adatbázisba mentve.

Managed/Persistenet: mentett entitás, melyet az EntityManager kezel.

Detached : mentett entitás, de már nem kezeli az EntityManager. Az objektum változások nem lesznek az adatbázisba mentve, míg az entitást kézzel nem mergelik.

Removed : az entitás eltávolításra kerül az adatbázisból, az EntityManager sem kezeli többé.


Entitások között - a táblákhoz hasonlóan - összesen négyféle leképezést használhatunk:

@OneToOne : 1-1 kapcsolat. Egy programozóhoz csak egy cím tartozhat és egy címhez csak egy programozó.

@OneToMany : 1-n kapcsolat. Egy programozóhoz n darab telefon tartozhat, de egy telefonnak csak egy tulajdonosa lehet.

@ManyToOne: n-1 kapcsolat. Az előző példa másik oldala, egy adott telefonhoz csak egy programozó tartozhat.

@ManyToMany : n-n kapcsolat. Egy programozó n programozási nyelvet tanulhatott és egy programozási nyelvet n programozó ismerhet.


A FetchType leírja a kapcsolt mezők viselkedését betöltésnél. Két értéke lehet, Lazy és Eager. @ManyToOne és @OneToOne kapcsolat esetén az érték Eager, egyéb esetben Lazy. Természetesen átállítható, pl: @OneToOne(fetch = FetchType.LAZY). Eager esetén a kapcsolt entitás is betöltésre az eredetileg kért entitás betöltésével egy időben, míg Lazy esetén csak az első hivatkozáskor.

Az entitások tervezésénél komoly figyelmet igényel a FetchType -ok megfelelő kezelése, hiszen egy egyszerűbb lekérdezéssel is betölthetjük akár az egész adatbázist, ami komoly performancia gondokat okozhat. Lazy esetén egy másik problémába is bele futhatunk. A tranzakció végén, amikor az EntityManager lezárul, az entitás detached állapotba kerül. Ha detached entitás Lazy mezőjét akarjuk elkérni, a Jpa már nem tudja betölteni az adatbázisból, így LazyInitializationException kivételt kapunk. A problémára többféle megoldás létezik (sql lekérés, entity graph ... ), melyeket később tárgyalunk.

A példához az alábbi adatbázis struktúrát képezzük le:

picture alt

A programmer táblából indulunk ki, mely tartalmazza a programozók adatait. A tábla 1-n kapcsolatban áll a phone táblával, tehát egy programozónak több telefonja lehet, de egy telefon csak egy adott programozóhoz tartozhat.

Az address táblával 1-1 kapcsolatban áll, egy programozóhoz egy lakcím tartozhat, valamint egy lakcímen csak egy programozó lakhat (természetesen lakhatna több is, de 1-1 kapcsolatot szimulálunk).

A programmer és a programming_language tábla n-n kapcsolatban áll, hisz egy programozási nyelvet több programozó is ismer és egy programozó több nyelvet is megtanulhat.

Jpa-ban a kapcsolatokat irányuk alapján két csoportba soroljuk:

Unidirectional : egyirányú kapcsolat. Csak a tulajdonos oldalon jelenik meg a kapcsolt entitás. A programming_language táblával ilyen irányú kapcsolatot alakítunk ki.

Bidirectional : kétirányú kapcsolat. Mindkét oldalon megjelenik a kapcsolat, az inverz oldalon csak jelöljük, ki a kapcsolat tulajdonosa ( mappedBy beállítással)


Az entitások tervezésénél először kiemeljük a közös mezőket, ezek az id, version, created_at és updated_at. Ezeket kiszervezzük egy ősosztályba. A Jpa támogatja az ősosztályok mezőinek leképezését. Erre a @MappedSuperclass annotációt használjuk, mely jelzi, hogy a mezők nem külön táblában foglalnak helyet, hanem az alosztályhoz kapcsolt táblába vannak leképezve.

@MappedSuperclass
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public abstract class BaseEntity implements Serializable {

    @Id
    @Column(name = "id", nullable = false, unique = true, updatable = false, length = 32)
    protected String id = IdGenerator.generateId();

    @Version
    protected Long version;

    @Column(name = "created_at", nullable = false)
    protected LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    protected LocalDateTime updatedAt;

    @PreUpdate
    protected void preUpdate() {
        setUpdatedAt(LocalDateTime.now());
    }

    @PrePersist
    protected void prePersist() {
        setCreatedAt(LocalDateTime.now());
        preUpdate();
    }

    @Transient
    public boolean isNew() {
        return Objects.isNull(version);
    }

}

A @Getter, @Setter és @EqualsAndHashCode annotációk Lombok specifikusak, automatikusan generálják a jelölt metódusokat.

Az adatbázisuk elsődleges kulcsát (primary key) @Id annotációval jelöljük. Adatbázisban minden táblánál érdemes elsődleges kulcsot használni. A @Column annotációval a tábla egy oszlopát képezzük le. Beállíthatjuk a leképezett oszlop nevét, hosszát és egyéb paramétereit. Esetünkben az id egy generált, String típusú oszlop. Ennek okára később visszatérük. Az id az entitás életciklusa alatt nem változik, ezért az updatable beállítás false értéket kap, ami megakadályozza a mező felülírását adatbázisban. Alapértelmezetten a Jpa az id mezőt használva döntési el, hogy új vagy egy már mentett objektummal dolgozik. Mivel az id -t generáljuk, ezt a szerepet a version mező veszi át, melyet a @Version annotációval jelölünk. A keretrendszer automatikusan megemeli a verzió számát egyel minden update-nél. A Spring Data Jpa használatával, a mező jelenléte esetén automatikusan a version mező alapján érzékeli, - az id helyett - új vagy mentett objektummal dolgozik-e. Amennyiben nem Spring Data Jpa-t használunk, akkor p.l. az mapping fájlban is megadhatjuk, mely mezőt használja ennek eldöntésére:

<hibernate-mapping package="com.zlrx.database.domain">

  <class name="Programmer" table="Programmer">

    <id name="id" column="id">
      <generator class="assigned" />
    </id>

   <version name="version" column="version" unsaved-value="null" />

    <!-- other fields -->

  </class>

</hibernate-mapping>

Az unsaved-value="null" megadja a rendszernek, hogy a null version mezőt figyelje a null id helyett.

A @PrePersist és @PreUpdate annotációk segítségével insert és update előtti műveleteket állíthatunk be. Esetünkben az entitás létrejöttének, valamint az utolsó módosításának idejét állítjuk be, mely minden mentésnél automatikusan megtörténik, nem szükséges manuálisan beállítani a mező értékét. @Transient annotációval olyan mezőt illetve metódust adhatunk az entitáshoz, mely nem kerül mentésre az adatbázisban. Az isNew() hívásakor dől el, milyen entitásról van szó, nem adatbázisból érkezik az adat.

A BaseEntity szülő osztályból származva képezzük le a táblákat entitásokká, majd beállítjuk a mezőket és táblakapcsolatokat.

@Getter
@Setter
@Entity
@Table(name = "programmer")
public class Programmer extends BaseEntity {

    @Basic
    private String name;

    //hibernate specific
    @Type(type = "yes_no")
    private Boolean senior;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "address_id")
    private Address address;

    @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Phone> phones;

    @ManyToMany
    @JoinTable(name = "programmer_to_proglanguage",
            joinColumns = @JoinColumn(name = "programmer_id", referencedColumnName = "id"),
            inverseJoinColumns = @JoinColumn(name = "prog_lang_id", referencedColumnName = "id"))
    private List<ProgrammingLanguage> programmingLanguages;

}

Az @Entity annotáció jelzi, hogy az osztály egy entitás.

A @Table annotáció megadja a táblát, amelyet az entitás leképez. Alapesetben az osztály neve alapján próbálja meg feloldani a táblát, de a name beállítással megadhatjuk a leképezendő tábla nevét.

A @Basic annotáció - hasonlóan a @Column -hoz - egy mező - oszlop összerendelést jelez, minimális beállítási lehetőségekkel. Például nem adhatjuk meg a mező adatbázisbeli nevét, az adatbázisban kötelezően a mezővel megegyező néven kell szerepelnie az oszlopnak.

A @Type egy Hibernate specifikus annotáció. Segítségével megadhatjuk, milyen típusú adatbázis mezőre képezze le a Javaban használt típust. Mivel nem minden adatbázis támogat logikai mezőket, így a boolean típust char(1) típusú mezőre mappeljük, mely Y és N értéket vehet fel.

A address, phones és programmingLanguages mezőkben más entitásokat képezünk le.

Az address 1-1 kapcsolatban áll a programmer táblával, így a @OneToOne annotációt használjuk. A fetch beállítást LAZY értékre állítjuk, mely jelzi hogy az entitás betöltésénél nem szeretnénk a kapcsolt entitást is betölteni. A cascade beállítás megadja, a kapcsolt entitásra is végrehajtódjon-e a Programmer entitáson végrehajtott művelet. Pédául egy programozó törlése esetén automatikusan törlődjön-e a kapcsolt address adatbázis bejegyzés is. Természetesen beállíthatjuk egyes műveletekre is, pl REMOVE, MERGE .. cascade típusok használatával, vagy - ahogy a példában is szerepel - minden műveletre.

@JoinColumn annotáció segítségével megadjuk, hogy a programmer táblában az address tábla id mezője az address_id mezőbe kerüljön mentésre (foreign key). A kapcsolatot a programmer tábla tárolja. Az Address entitásban pedig megadjuk, hogy a Programmer entitás address mezője tárolja a kapcsolatot (mappedBy="address").

A phones mező listában tárolja a programozóhoz tartozó telefonokat. 1-n kapcsolatban áll a két tábla. A kapcsolatot a phone tábla tárolja, hisz egy programozó tartozhat egy telefonhoz, így a Programmer entitásban csak azt kell megadnunk, hogy a Phone melyik mezőben tárolja a kapcsolatot: _@OneToMany(mappedBy = "owner"). _ A Jpa ilyen esetben látja, telefonokra milyen kapcsolat segítségével tud lekérdezni: select * from phone where owner_id=programmer.id

Az orphanRemoval beállítás biztosítja, amennyiben egy Programmer rekordot törlünk, vagy egy Phone rekordot eltávolítunk a Programmer entitás phones listájából, akkor a hozzá tartozó phone rekord __ is automatikusan törlődjön. Ez esetben nem csak a kapcsolat, hanem rekord is törlődik adatbázisból.

A programmer és programming_languages n-n kapcsolatban áll. Ezt a kapcsolat típust nem lehet egyik táblába sem tárolni, hisz mindkét oldalon n darab id tartozhat az adott rekordhoz, ezért egy köztes táblát hozunk létre, mely a tartalmazza a programmer id -ját és a programming_languages id -ját is. Pl:

programmer.id programming_language.id
1 4
1 6
1 9
2 5
2 7

A @JoinTable annotáció leírja a kapcsólótábla nevét, valamint az azonosítókat tároló oszlopokat is megadja. A programmer tábla id mezője a programmer_id mezőben, a programming_language táblájé pedig a prog_lang_id mezőben tárolódik.

@Getter
@Setter
@Entity
@Table
public class Address extends BaseEntity {

    @Basic
    private Integer zip;

    @Basic
    private String city;

    @Basic
    private String street;

    @Column(name = "house_number")
    private Integer houseNumber;

    @OneToOne(mappedBy = "address")
    private Programmer programmer;

}

A @OneToOne mező a mappedBy beállítás segítségével leírja, a kapcsolt entitás melyik mezője tárolja a kapcsolatot. A példában a Programmer entitásban lévő address mező tárolja, mely pedig leírja az adatbázis oszlop nevét.

@Getter
@Setter
@Entity
@Table
public class Phone extends BaseEntity {

    @Basic(optional = false)
    @Enumerated(value = EnumType.STRING)
    private Producer producer;

    @Basic
    private String type;

    @Column(name = "imei", length = 50, nullable = false, unique = true)
    private String imei;

    @Column(name = "price", nullable = false, precision = 2)
    private Double price;

    @ManyToOne
    @JoinColumn(name = "owner_id")
    private Programmer owner;

}

A producer mezőben egy enumot képezünk le adatbázisban. Vegyük a következő enumot:

public enum Producer {
    LG, Apple, Samsung
}

@Enumerated(value = EnumType.STRING) segítségével az enum karakterlánc típusú oszlopban tárolódik, ahová az enum neve kerül elmentésre (pl LG vagy Apple). Választhatjuk az ORDINAL típust is, ekkor az enum sorszáma kerül egy egész szám típusú mezőbe (LG=0, Apple=1)

A @Column annotáció beállításai között megadhatjuk az adott mező egyedi-e. Ebben az esetben adatbázis oldalon egy értéket csak egy sor tartalmazhat. A lenght beállítással megadatjuk a oszlop maximális hosszát. A precision pedig lebegőpontos számoknál a tört helyiérték maximális pontosságát állítja be.

@Getter
@Setter
@Entity
@Table(name = "programming_language")
public class ProgrammingLanguage extends BaseEntity {

    @Basic
    private String name;

    @Basic
    private String inventor;

    @Column(name = "release_date")
    private LocalDate release;

}

A példakód letölthető GitHubról.

Clone this wiki locally