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ą właściwość do stanu domyślnego, musiałem poświęcić trochę czasu na 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, że 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, że 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package pl.devcezz.createemptycomposites.next;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
class ProductIntegrationTest {

  @Autowired
  ProductService productService;

  @Test
  void verify() {
    //given
    Long productId = productService.createProduct("wardrobe");

    //when/then
    assertThatThrownBy(() -> productService.incrementQuantity(productId))
        .isInstanceOf(NullPointerException.class);

    //when
    productService.assignLabelFor(productId, "wood");

    //then
    String firstResult = productService.printLabels(productId);
    assertThat(firstResult).isEqualTo("wood");

    //when/then
    assertThatThrownBy(() -> productService.fetchQuantity(productId))
        .isInstanceOf(NullPointerException.class);    

    //when
    productService.makeEmbeddableFieldsToBeNull(productId);

    //then
    String secondResult = productService.printLabels(productId);
    assertThat(secondResult).isEqualTo("");
  }
}

Ż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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 właściwie 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 właściwości ‘hibernate.create_empty_composites.enabled’. Domyślnie jest ona wyłączona. Oznacza to, że jeśli 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śli 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, że pole metaData klasy Product zostanie zainicjalizowane.

Polu typu ProductQuantity nie pomoże nawet 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.

1
2
3
4
5
6
7
8
9
10
  //...

  @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ł nawet jeśli 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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, że 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śli nie chcemy się martwić weryfikacją czy dane pole @Embedded jest null to lepiej nadawać w nim domyślne wartości albo czekać aż właściwość ‘hibernate.create_empty_composites.enabled’ wyjdzie z fazy eksperymentalnej. Natomiast czasem może zdarzyć się sytuacja, że jedno pole musi mieć wartość null, gdy inne jest zinstancjonowane. Jakie jest Twoje zdanie na ten temat? Proszę, podziel się swoją opinią w komentarzu.