Ostatnio w pracy, w ramach zadania, musiałem naprawić występowanie jednego z błędów. Bug był spowodowany wykorzystywaniem eksperymentalnej flagi ’hibernate.create_empty_composites.enabled’. Gdy przywróciłem tą adekwatność do stanu domyślnego, musiałem poświęcić trochę czasu w przeróbki w kodzie. Dzięki tej rzemieślniczej pracy dowiedziałem się pewnej rzeczy na temat @Embeddable, którą chciałbym się z Tobą podzielić.
Nakreślenie sytuacji
Załóżmy, iż mamy encję Product, która zawiera w sobie dwa pola. Każde z nich posiada nad sobą adnotację @Embedded. Odpowiedzialnością pierwszego pola jest agregacja metadanych takich jak np. etykiety. Drugie natomiast przechowuje informację o ilości danego produktu na stanie. Warto zwrócić uwagę na to, iż w klasie MetaData mamy zbiór literałów określających wcześniej wspomniane etykiety. Ma to ogromne znaczenie w dalszej części artykułu.
package pl.devcezz.createemptycomposites.next; import javax.persistence.Embedded; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; @Entity public class Product { @Id @GeneratedValue private Long id; private String name; @Embedded private MetaData metaData; @Embedded private ProductQuantity quantity; protected Product() { } private Product(String name) { this.name = name; } public static Product of(String name) { return new Product(name); } public void incrementQuantity() { quantity.increment(); } public void addLabel(String label) { this.metaData.addLabel(label); } public String printLabels() { return metaData.print(); } public Long fetchQuantity() { return quantity.getQuantity(); } public void makeEmbeddableFieldsToBeNull() { this.metaData = null; this.quantity = null; } public Long getId() { return id; } } package pl.devcezz.createemptycomposites.next; import javax.persistence.ElementCollection; import javax.persistence.Embeddable; import java.util.Set; @Embeddable public class MetaData { @ElementCollection private Set<String> labels; public void addLabel(final String label) { labels.add(label); } public String print() { return String.join(",", labels); } } package pl.devcezz.createemptycomposites.next; import javax.persistence.Access; import javax.persistence.AccessType; import javax.persistence.Embeddable; @Embeddable @Access(AccessType.FIELD) public class ProductQuantity { private Long quantity; public void increment() { quantity++; } public Long getQuantity() { return quantity; } }Weryfikacja działania kodu
Spróbujemy teraz napisać test integracyjny, który będzie robił następujące rzeczy:
- doda nowy produkt
- spróbuje zwiększyć ilość sztuk nowego towaru
- doda do niego etykietę
- pobierze etykiety
- spróbuje pobrać ilość sztuk
- oznaczy pola jako null
- pobierze ponownie etykiety
Żeby móc uruchomić powyższy test trzeba napisać implementację serwisu ProductService. Nie będzie to nic nadzwyczajnego. Wykorzystamy repozytorium produktów, aby móc stworzyć jeden z nich oraz pobrać wybrany po ID. Na znalezionym obiekcie wywołamy dedykowaną metodę dla danej funkcji biznesowej. Puszczamy test i przechodzi on na zielono.
package pl.devcezz.createemptycomposites.next; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional public class ProductService { private final ProductRepository productRepository; public ProductService(ProductRepository shopRepository) { this.productRepository = shopRepository; } public Long createProduct(String name) { Product product = Product.of(name); Product savedProduct = productRepository.save(product); return savedProduct.getId(); } public void assignLabelFor(Long productId, String label) { Product product = productRepository.findById(productId) .orElseThrow(IllegalArgumentException::new); product.addLabel(label); } public void incrementQuantity(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(IllegalArgumentException::new); product.incrementQuantity(); } public String printLabels(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(IllegalArgumentException::new); return product.printLabels(); } public Long fetchQuantity(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(IllegalArgumentException::new); return product.fetchQuantity(); } public void makeEmbeddableFieldsToBeNull(Long productId) { Product product = productRepository.findById(productId) .orElseThrow(IllegalArgumentException::new); product.makeEmbeddableFieldsToBeNull(); } }Czy, aby na pewno wszystko jest poprawne?
No dobrze, ale czemu adekwatnie w teście wykorzystuję metodę assertThatThrownBy i łapię w niej wyjątek NullPointerException? Dlaczego napisałem ją przy wywołaniu incrementQuantity, ale przy assignLabelFor już nie? Przecież ani dla ProductQuantity, ani dla MetaData, nie jest wykorzystywany operator new.
Wróćmy do wcześniej wspomnianej adekwatności ’hibernate.create_empty_composites.enabled’. Domyślnie jest ona wyłączona. Oznacza to, iż jeżeli wszystkie pola dla obiektu klasy oznaczonej @Embeddable są bez wartości to ten obiekt będzie nullem, gdy będzie polem oznaczonym przez @Embedded. Czyli tak jak ma to miejsce dla ProductQuantity. jeżeli jego pole quantity będzie nullem to pole klasy Product o tej samej nazwie będzie również nullem. Stąd przy wywołaniu incrementQuantity dostaniemy NullPointerException.
Inaczej sytuacja ma się w przypadku, gdy jedno z pól klasy, oznaczonej przez @Embeddable, jest kolekcją. Dzięki magii Hibernate, pomimo braku jej inicjalizacji, tworzona jest kolekcja będąca jedną z implementacji AbstractPersistentCollection, np. dla Set będzie to PersistentSet. Wtedy pole labels klasy MetaData nie będzie nullem. Finalnie konsekwencją tego zdarzenia będzie fakt, iż pole metaData klasy Product zostanie zainicjalizowane.
Polu typu ProductQuantity nie pomoże choćby sytuacja, gdy skorzystamy dla niego z operatora new. Przy pobraniu obiektu Product z bazy danych to pole quantity i tak pozostanie nullem, gdy wszystkie w nim pola będą bez wartości.
//... @Embedded private MetaData metaData = new MetaData(); @Embedded private ProductQuantity quantity = new ProductQuantity(); //... }Jak temu zaradzić?
Musimy się zastanowić, co w sumie dla nas jest prawidłowym działaniem? Czy bardziej wolelibyśmy, aby dany obiekt @Embeddable się nie tworzył, gdy kolekcje w nim są puste (MetaData)? Czy jednak, żeby się tworzył choćby jeżeli wszystkie jego pola są nullem – (ProductQuantity)?
W przypadku drugiego wyboru może warto byłoby rozważyć weryfikację zapisywanych pól. W ten sposób będą one miały jakąś domyślną wartość i dzięki temu obiekt klasy je agregujące powstanie. Biorąc na tapet ProductQuantity, dla niego wartością startową mogłoby być oczywiście zero. Dodatkowo warto by dodać jeszcze dodać sprawdzenie, czy ktoś czasem nie przekazał do niego null albo liczby ujemnej. Wtedy możemy zacząć myśleć o koncepcji jaką jest Value Object.
Gdybyśmy wybrali pierwszą opcję to jedyne rozwiązanie, jakie na ten moment przyszło mi do głowy, to zastąpienie kolekcji literałem łączącym wartości przecinkiem. Zewnętrzny świat niekoniecznie musi wiedzieć, z czego korzystamy we wewnątrz danej klasy. Jej API w ogóle nie uległoby zmianie. Dla tego rozwiązania nie stworzymy żadnej dodatkowej tabeli do przechowywania wartości kolekcji w postaci osobnych wierszy.
package pl.devcezz.createemptycomposites.next; import javax.persistence.Embeddable; @Embeddable public class MetaData { private static final String DELIMITER = ","; private String labels; public void addLabel(final String label) { if (labels == null) { labels = label; } else { labels = labels + DELIMITER + label; } } public String print() { return labels; } }Przy takiej implementacji uruchomiony test nie przejdzie. Otrzymamy wyjątek NullPointerException w linijce productService.assignLabelFor(productId, "wood"); dotyczący pola metaData klasy Product. Właśnie o to nam chodziło!
Powstaje pytanie co w przypadku bardziej rozbudowanych typów kolekcji. Na szczęście tutaj ratuje nas specyfikacja JPA. Wychodzi na to, iż nie można mieć klasy oznaczonej przez @Embeddable posiadającej kolekcję innych klas @Embeddable. Uff, całe szczęście! Ale chwila… może Ty widzisz jakieś inne rozwiązania albo możliwości opisanego w tym artykule problemu?
Podsumowanie
Warto na koniec zastanowić się, które podejście jest lepsze. Czy to z nadawaniem wartości null polom @Embedded, gdy ich pola są bez wartości? Czy jednak odwrotnie, aby tworzyć instancje? Dobrą odpowiedzią jest „to zależy”. jeżeli nie chcemy się martwić weryfikacją czy dane pole @Embedded jest null to lepiej nadawać w nim domyślne wartości albo czekać aż adekwatność ’hibernate.create_empty_composites.enabled’ wyjdzie z fazy eksperymentalnej. Natomiast czasem może zdarzyć się sytuacja, iż jedno pole musi mieć wartość null, gdy inne jest zinstancjonowane. Jakie jest Twoje zdanie na ten temat? Proszę, podziel się swoją opinią w komentarzu.