CodeGym /Kursy /Spring Data JPA /Kolejność zapisu i integralność

Kolejność zapisu i integralność

Spring Data JPA
Poziom 3, Lekcja 1
Dostępny

1. Kiedy kolejność operacji jest ważna

Gdy tylko tabele są powiązane, kolejność operacji przestaje być drobiazgiem: każdy następny krok zależy od poprzedniego. Gdy dopiero zaczynasz pisać SQL, łatwo myśleć: „No i co, dwie tabele… no i co, trzy polecenia… baza przecież jest inteligentna". I baza naprawdę jest inteligentna — tylko nie w sensie „sama jakoś to rozwiąże", a w sensie „nie pozwoli ci zrobić głupoty".

Wyobraź sobie, że próbujesz wydać bilet lotniczy osobie bez paszportu. Na poziomie idei można to sobie wyobrazić, ale na poziomie prawdziwego lotniska powiedzą ci: „Najpierw paszport, potem bilet". Relacyjna baza danych — to takie lotnisko: surowe, formalne i nie poddające się namowom „no daj, wyjątkowo tym razem".

W mini-shop widać to ciągle. Produkt (product) musi odwoływać się do kategorii (category). Pozycja zamówienia (order_item) musi odwoływać się do zamówienia (customer_order) i do produktu (product). Stan magazynowy (stock_item) musi odwoływać się do produktu. I jeśli spróbujesz najpierw wstawić zależny wiersz, a potem główny, baza słusznie odpowie błędem. Nie dlatego, że jest złośliwa, lecz dlatego, że prosisz ją o przechowywanie uszkodzonego modelu.

2. Integralność referencyjna i FOREIGN KEY

Wiele osób zapamiętuje FOREIGN KEY jako coś „żeby JOIN-y działały". To nie jest właściwy model mentalny. JOIN-y będą działać i bez klucza obcego: SQL nie zabrania łączyć tabel po dowolnych kolumnach. Sens FOREIGN KEY jest inny: nie pozwala przechowywać odwołania do wiersza, który nie istnieje. To właśnie nazywa się integralnością referencyjną.

Ważne jest rozumienie, że klucz obcy działa nie tylko przy INSERT, ale i przy UPDATE i przy DELETE. Jeśli wstawiasz wiersz do tabeli-potomka i podajesz category_id = 99, baza sprawdza: czy w category jest wiersz z id = 99. Jeśli robisz UPDATE i zmieniasz category_id na nową wartość, baza znowu sprawdza. Jeśli próbujesz usunąć wiersz z tabeli-rodzica, do którego ktoś się odwołuje, baza na to nie pozwoli (jeśli nie skonfigurowałeś specjalnie innej polityki).

Mały przykład w znajomej nam formie „SQL jako string", żeby nie wchodzić w szczegóły JDBC:

// Próbujemy wstawić produkt odwołujący się do kategorii przez category_id.
String insertProduct = """
    insert into product (id, sku, name, price, category_id)
    values (101, 'PHONE-002', 'Phone', 599.99, 99)
    """;

// Ważne: jeśli category.id = 99 nie istnieje — zadziała sprawdzenie FOREIGN KEY i baza zwróci błąd.
// Przybliżony sens błędu: naruszenie ograniczenia klucza obcego przy insert/update w product.

Tu ważne nie jest dokładny komunikat błędu, a przyczyna: próbowałeś zapisać odwołanie do nieistniejącego wiersza. Baza woli cię zatrzymać od razu, niż przechowywać dane, których potem nie można poprawnie zinterpretować.

3. INSERT: rodzic → dziecko

Zacznijmy od najprostszego: kategoria i produkt. Kategoria — to słownik, „rodzic". Produkt — „dziecko", bo przechowuje category_id. Znaczy, kategoria musi istnieć wcześniej, niż będziesz mógł wstawić produkt, który się do niej odwołuje. To nie zalecenie, lecz bezpośrednia konsekwencja klucza obcego.

Prawidłowa kolejność wygląda nudno, ale zawsze działa. Najpierw tworzymy kategorię, potem produkt:

// Krok 1: tworzymy "rodzica" — kategorię.
String insertCategory = """
    insert into category (id, code, name, active)
    values (10, 'ELEC', 'Electronics', true)
    """;

// Krok 2: tworzymy "dziecko" — produkt odwołujący się do category.id przez product.category_id.
String insertProduct = """
    insert into product (id, sku, name, price, category_id)
    values (100, 'PHONE-001', 'Phone', 499.99, 10)
    """;

Jeśli zamienisz kroki miejscami i spróbujesz wstawić produkt jako pierwszy, dostaniesz błąd klucza obcego, bo category_id = 10 jeszcze nie istnieje w tabeli category. I to dobry moment, żeby wyrobić sobie przydatny nawyk: zawsze trzymaj w głowie pytanie „jakie odwołania tworzę tym rekordem?"

I jeszcze jedna myśl, która początkowo wydaje się nudna: w WHERE dla kolejnych operacji lepiej opierać się na stabilnych identyfikatorach (id, code, sku). Na przykład jeśli potem zechcesz zmienić cenę produktu, lepiej znaleźć go po id lub sku, a nie po name, bo nazwa — to rzecz ludzka: może się zmienić, zawierać literówki i w ogóle nie musi być unikalna.

4. INSERT: zamówienie i pozycje

Teraz skomplikujmy obraz do scenariusza zamówienia. Zamówienie — to nie „jeden rekord", a zestaw rekordów: główny wiersz zamówienia w customer_order i kilka wierszy pozycji w order_item. Pozycja zamówienia odwołuje się do zamówienia przez order_id. Znaczy, zamówienie musi istnieć wcześniej, niż będziemy mogli wstawić pozycje.

Tu warto poczuć różnicę między logiką dziedzinową a mechaniką tabel. W domenie myślimy: „złożyć zamówienie z dwoma produktami". A baza myśli: „najpierw wiersz-rodzic, potem wiersze-dzieci, bo dzieci mają FK". I to normalne: jedna operacja dziedzinowa rozkłada się na kilka technicznych kroków.

Minimalny wariant wygląda tak:

// Najpierw wstawiamy "nagłówek" zamówienia: to rodzic dla order_item (FK po order_id).
String insertOrder = """
    insert into customer_order (id, order_number, status, customer_email, total_amount)
    values (900, 'ORD-900', 'NEW', 'alex@example.com', 399.98)
    """;

// Potem wstawiamy pozycję: ma od razu dwie zależności (order_id i product_id muszą istnieć).
String insertItem = """
    insert into order_item (id, order_id, product_id, quantity, unit_price, line_total)
    values (901, 900, 10, 2, 199.99, 399.98)
    """;

I tu pojawia się jeszcze jeden ważny niuans: order_item zależy nie tylko od zamówienia, ale i od produktu. Tzn. żeby wstawić pozycję zamówienia, muszą istnieć zarówno customer_order.id = 900, jak i product.id = 10. Dlatego pozycja zamówienia — to „dziecko z dwojgiem rodziców" (brzmi dramatycznie, ale w SQL to normalny przypadek).

Jeśli po drodze okaże się, że potrzebnego produktu nie ma (albo pomyliłeś product_id), baza znowu uczciwie odmówi udawania, że wszystko jest w porządku. I to najlepsza weryfikacja rzeczywistości twojego scenariusza zapisu: w jednym poleceniu można napisać wszystko, ale schemat z FK nie pozwoli zapisać bezsensownego odwołania.

5. DELETE: kolejność odwrotna

Usuwanie — to lustrzana historia do wstawiania. Jeśli przy INSERT idziemy od rodzica do dziecka, to przy DELETE najczęściej trzeba iść od dziecka do rodzica. Powód jest ten sam: klucz obcy chroni model. Jeśli w order_item są wiersze odwołujące się do customer_order, to zamówienia nie można usunąć — inaczej w order_item pozostaną „osieroconе" pozycje odwołujące się do nieistniejącego order_id.

Dla zamówienia wygląda to szczególnie wyraźnie:

// Krok 1: usuwamy "dzieci" (order_item), inaczej FK nie pozwoli usunąć zamówienia.
String deleteItems = """
    delete from order_item
    where order_id = 900
    """;

// Krok 2: usuwamy "rodzica" (customer_order), gdy nikt już się do niego nie odwołuje.
String deleteOrder = """
    delete from customer_order
    where id = 900
    """;

Jeśli spróbujesz usunąć zamówienie jako pierwszy krok i w bazie są pozycje, dostaniesz błąd klucza obcego (w sensie: „nie można usunąć wiersza, do którego ktoś się odwołuje"). I w tym momencie ważne jest, żeby nie gniewać się na bazę, lecz cieszyć się: nie pozwoliła ci zamienić danych w śmieci.

Czasem na poziomie schematu konfiguruje się kaskadowe usuwanie — żeby przy usunięciu zamówienia automatycznie usuwały się jego pozycje. To osobna polityka i dla niektórych relacji jest naprawdę właściwa, ale sama idea „kolejność jest ważna" nigdzie nie znika. Nawet jeśli kaskada jest włączona, i tak musisz rozumieć, co dokładnie zostanie usunięte i dlaczego. Baza nie czyta twoich myśli — czyta ograniczenia i reguły schematu.

6. UPDATE: zmiana klucza obcego

UPDATE często postrzega się jako „no to po prostu zmienić parę pól". Ale gdy tylko aktualizujesz kolumnę klucza obcego, UPDATE przestaje być „zwykłą aktualizacją" i zamienia się w operację zmiany powiązania między encjami. Czyli faktycznie mówisz bazie: „ten produkt teraz należy do innej kategorii" lub „ta pozycja zamówienia teraz należy do innego zamówienia" (drugi pomysł zazwyczaj pachnie kłopotami biznesowymi, ale jako przykład mechaniki nadaje się).

Weźmy bezpieczny scenariusz: produkt przenosi się z jednej kategorii do innej. Z punktu widzenia SQL to zmiana category_id:

// Zmieniamy powiązanie "produkt → kategoria": FK będzie sprawdzony, jak przy INSERT.
String moveProductToAnotherCategory = """
    update product
    set category_id = 20
    where id = 100
    """;

To polecenie zadziała tylko jeśli kategoria id = 20 naprawdę istnieje. Jeśli podasz nieistniejącą kategorię, baza zwróci błąd klucza obcego — tak samo jak przy nieprawidłowym INSERT. I to dobry moment, żeby sformułować regułę: `FOREIGN KEY` kontroluje nie konkretne polecenie, lecz sens powiązania. Każdy sposób tworzenia lub zmieniania odwołania — wstawianie lub aktualizacja — będzie sprawdzany.

W praktycznym życiu wygląda to bardzo przyziemnie: najpierw tworzysz potrzebny „rodzicielski" rekord lub upewniasz się, że już istnieje, i dopiero potem aktualizujesz „dziecięcy". Logika kolejności znowu bierze górę nad „a może jakoś przejdzie".

7. Mapa zależności mini-shop

Gdy tabel jest więcej niż dwie, mózg zaczyna cicho prosić „zapisz i wyjdź". Dlatego przydatne jest raz narysowanie mapy zależności: kto odwołuje się do kogo. W mini-shop jest ona dość kompaktowa, ale już pokazuje typowy schemat: słowniki, encje katalogu, zamówienia, pozycje.

Oto uproszczony schemat (strzałka oznacza: „po lewej przechowuje FK na prawo"):

flowchart TD
    Product --> Category
    StockItem --> Product
    OrderItem --> CustomerOrder
    OrderItem --> Product

Z tego obrazka automatycznie wynika kolejność wstawiania: najpierw Category, potem Product, potem, jeśli trzeba, StockItem. Dla zamówień: najpierw CustomerOrder, potem OrderItem. A dla usuwania — w odwrotną stronę: najpierw OrderItem, potem CustomerOrder; najpierw StockItem, potem Product, i dopiero potem, jeśli w ogóle potrzeba, Category.

Żeby trzymać to jeszcze łatwiej w głowie, można zwinąć wszystko do małej tabeli. Nie zastępuje ona rozumienia, ale jako „ściągawka dla mózgu" działa doskonale:

Powiązanie Gdzie FK Kto „rodzic" Kto „dziecko" Wstawianie (typowa kolejność) Usuwanie (typowa kolejność)
product → category product.category_id category product category → product product → category
stock_item → product stock_item.product_id product stock_item product → stock_item stock_item → product
order_item → customer_order order_item.order_id customer_order order_item customer_order → order_item order_item → customer_order
order_item → product order_item.product_id product order_item product → order_item order_item → product

I jeszcze jedna ważna uwaga: czasem technicznie można, ale z punktu widzenia biznesu nie warto. To normalne. SQL daje ci narzędzia, a odpowiedzialność za sens danych i tak pozostaje po stronie programisty.

8. Orkiestracja w Javie: kolejność kroków

Gdy piszesz kod backendowy, bardzo szybko przestajesz wykonywać SQL ręcznie. Ale kolejność nigdzie nie znika: po prostu przenosi się z twojej głowy do kodu serwisu. I jeśli kolejność nie jest przemyślana, w kodzie zaczyna to wyglądać jak „czasem pada z błędem FK". Najbardziej nieprzyjemne — takie błędy często wypływają nie od razu, ale gdy ktoś po raz pierwszy wypróbuje rzadki scenariusz.

Na razie nie wchodzimy w pełny kod JDBC, ale można sobie wyobrazić „wykonawcę SQL" jako abstrakcyjną metodę execute(sql). Zależy nam nie na technice wykonania, a na samej sekwencji:

I tu pojawia się następne praktyczne pytanie: prawidłowa kolejność nie sprawia jeszcze, że scenariusz jest szybki. Gdy danych jest dużo, bazie mało po prostu nie naruszyć FOREIGN KEY — musi jeszcze szybko znajdować rodziców i dzieci po category_id, product_id, order_id.

import java.util.List;

public class OrderSqlScenario {

    public List<String> stepsForDeletingOrder(long orderId) {
        // 1) Najpierw usuwamy "dzieci" po FK: order_item odwołuje się do customer_order przez order_id.
        String deleteItems = """
            delete from order_item
            where order_id = %d
            """.formatted(orderId); // Podstawiamy konkretne id zamówienia do szablonu SQL.

        // 2) Potem usuwamy "rodzica": teraz FK już nie przeszkadza.
        String deleteOrder = """
            delete from customer_order
            where id = %d
            """.formatted(orderId);

        return List.of(deleteItems, deleteOrder); // Kolejność — to właśnie sens scenariusza.
    }
}

Tak, wygląda to niemal śmiesznie prosto. Ale właśnie takie „proste" miejsca potem oszczędzają godziny. Czytasz kod i od razu widzisz: najpierw usuwamy dzieci, potem rodzica. Nie trzeba pamiętać tego jako magicznej reguły — leży prosto w kolejności kroków.

Tak samo można opisać tworzenie produktu w kategorii: najpierw upewnić się, że kategoria istnieje (lub ją wstawić), potem wstawić produkt. W każdym wariancie wykonania SQL i tak będziesz wracać do tego samego modelu mentalnego: FK dyktuje dopuszczalną kolejność operacji.

9. Typowe błędy przy zapisie do powiązanych tabel

W temacie powiązanych tabel błędy zazwyczaj nie są „złożone", lecz „ludzkie": mózg zmęczony, pomylił kolejność, pomylił id, zapomniał o zależności. I co najciekawsze — baza danych niemal zawsze reaguje uczciwie i bezpośrednio. Trzeba tylko nauczyć się czytać jej reakcję jako wskazówkę, że naruszyłeś reguły modelu, a nie jako „losową awarię".

Błąd nr 1: wstawianie „dziecka" przed „rodzicem".
To klasyka: próbują wstawić product z category_id, gdy kategoria jeszcze nie jest stworzona. Rozwiązanie nie polega na „jakoś wyłączyć sprawdzenia", lecz na uprzednim zaplanowaniu scenariusza: najpierw stworzyć kategorię, potem stworzyć produkt. Jeśli kategoria powinna już istnieć ze swojego sensu, to przed INSERT produktu trzeba ją znaleźć i upewnić się, że jest.

Błąd nr 2: usuwanie „rodzica", gdy pozostały mu „dzieci".
Usunięcie customer_order przy istniejących order_item niemal gwarantowanie skończy się błędem FK. Prawidłowe myślenie tu jest bardzo proste: jeśli wiersze-dzieci odwołują się do rodzica, najpierw trzeba usunąć wiersze-dzieci (lub świadomie skonfigurować politykę kaskadową na poziomie schematu, rozumiejąc konsekwencje). Inaczej zostawiasz „wiszące odwołania", a baza na to nie pozwala.

Błąd nr 3: mylenie identyfikatorów z różnych tabel.
Początkujący często wpisują category_id „na oko", bo „no pamiętam, że tam było 10". W prawdziwym projekcie szybko zamienia się to w chaos: możesz przypadkowo odwołać się do nieistniejącego rekordu albo po prostu do złego. Lekarstwo jest proste: zawsze rozumieć, z której tabeli pochodzi id, i w miarę możliwości opierać się na stabilnych kluczach biznesowych (code, sku, order_number) przy szukaniu potrzebnych wierszy, a id używać jako wynik tego szukania.

Błąd nr 4: zmiana klucza obcego przez UPDATE bez sprawdzenia istnienia nowego „rodzicielskiego" rekordu.
Polecenie update product set category_id = 999 wygląda niewinnie, dopóki nie okaże się, że kategorii 999 nie ma. FK jest sprawdzany też przy UPDATE, więc to gwarantowanie się nie powiedzie. Prawidłowa kolejność tu jest taka sama, jak przy INSERT: najpierw zapewnić istnienie rodzica, potem zmienić odwołanie.

Błąd nr 5: próba „leczenia" błędu FK przez wyłączenie ograniczeń zamiast naprawienia scenariusza.
Czasem chce się „chwilowo wyłączyć klucz obcy", zapisać dane, a potem „włączyć z powrotem". To mniej więcej jak zdjąć pasy bezpieczeństwa, bo przeszkadzają pięknie wylecieć przez przednią szybę. FOREIGN KEY — to nie ozdoba, a ochrona modelu. Jeśli przeszkadza, znaczy to, że scenariusz zapisu jest źle zaprojektowany lub dane wejściowe nie odpowiadają niezmiennikom.

1
Zadanie
Spring Data JPA, poziom 3, lekcja 1
Niedostępne
Wstawianie produktu do istniejącej kategorii
Wstawianie produktu do istniejącej kategorii
1
Zadanie
Spring Data JPA, poziom 3, lekcja 1
Niedostępne
Usuwanie zamowienia i jego pozycji we wlasciwej kolejnosci
Usuwanie zamowienia i jego pozycji we wlasciwej kolejnosci
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION