BeanPropertyRowMapper, czyli na co uważać

Wraz z rozwojem aplikacji AnimalShelter natrafiam na ciekawe przypadki. Jeden z nich dotyczył klasy BeanPropertyRowMapper służącej do mapowania wiersza bazodanowego do instancji klasy. Błąd wyszedł na jaw dopiero podczas testów integracyjnych. Wiersz, który chciałem wyciągnąć z bazy nie mapował pól do odpowiedniego obiektu. Otrzymywałem po prostu domyślne wartości w polach takie jak np. null czy 0. Przejdźmy zatem do sedna problemu.

Analizowany przypadek

Zaczynając od krótkiego wprowadzenia, jednym z zadań aplikacji jest przyjmowanie zwierząt do schroniska. Jednak nic nie jest workiem bez dna, więc i nasze schronisko ma swoje limity miejsc. Z tego powodu powstała klasa pakietowa ShelterAnimalRow, która reprezentuje już przyjęte zwierzątko do schroniska. Dzięki niej jesteśmy w stanie pobrać aktualną listę zwierząt i sprawdzić czy mamy jeszcze miejsce, aby przyjąć kolejne pupile. Już teraz warto zwrócić uwagę, że wszystkie elementy w tej klasie ustawiłem na dostęp pakietowy: samą klasę, pola, konstruktor oraz setter (SPOILER!).

class ShelterAnimalRow {

    UUID animal_id;

    ShelterAnimal toShelterAnimal() {
        return new ShelterAnimal(new AnimalId(animal_id));
    }

    void setAnimal_id(final UUID animal_id) {
        this.animal_id = animal_id;
    }
}

Przy okazji warto zaznaczyć, że korzystam z biblioteki vavr stąd zapis Stream.ofAll(...). Następnie przy pomocy JdbcTemplate wykonywane jest zapytanie SELECT pobierające tylko te rekordy z bazy, których wartość kolumny adopted_at jest null. Musimy zmapować pobrane dane na klasę ShelterAnimalRow wykorzystując właśnie BeanPropertyRowMapper. Robimy to tworząc jego nowy obiekt używając konstruktora przyjmującego parametr typu java.lang.Class. Kolejnym krokiem jest zmapowanie wszystkich elementów strumienia na klasę ShelterAnimal i wrzucenie całości do zbioru.

@Override
public Set<ShelterAnimal> queryForAnimalsInShelter() {
    return Stream.ofAll(
            jdbcTemplate.query(
                "SELECT a.animal_id FROM shelter_animal a WHERE a.adopted_at IS NULL",
                new BeanPropertyRowMapper<>(ShelterAnimalRow.class)
            ))
            .map(ShelterAnimalRow::toShelterAnimal)
            .toSet();
}

Pora na test

Teraz pora przetestować nasze rozwiązanie. Wykorzystamy do tego celu test integracyjny, który będzie używał Testcontainers stawiając cały kontekst Springa. Dzięki temu odwzorujemy produkcyjne działanie aplikacji.

@Test
@Transactional
@DisplayName("Should fetch animals which are in shelter")
void should_fetch_animals_which_are_in_shelter(@Autowired ShelterDatabaseRepository repository) {
    Animal animal = animal();
    repository.save(animal);

    Set<ShelterAnimal> shelterAnimals = repository.queryForAnimalsInShelter();

    assertThat(shelterAnimals).containsOnly(new ShelterAnimal(animal.getId()));
}

Po uruchomieniu testu od razu dostaniemy wyjątek java.lang.IllegalArgumentException: id cannot be null. Jest to zabezpieczenie przed stworzeniem nieprawidłowego obiektu klasy AnimalId. Oznacza to, że zmiennej UUID animal_id klasy ShelterAnimalRow została przypisana wartość null. Tylko dlaczego skoro stworzyliśmy obiekt Animal, który ma podany losowy UUID i zapisaliśmy go w bazie? Odpowiedź została znaleziona w dokumentacji, a dokładniej w Javadoc klasy BeanPropertyRowMapper.

Column values are mapped based on matching the column name as obtained from result set meta-data to public setters for the corresponding properties. The names are matched either directly or by transforming a name separating the parts with underscores to the same name using „camel” case.

Wynika z tego, że klasa może być pakietowa, konstruktor i pola też, ale setter musi być PUBLICZNY! W innym przypadku dane pole po prostu nie zostanie wypełnione danymi. Robiąc szybką poprawkę test od razu przechodzi na zielono. Z dokumentacji możemy dodatkowo dowiedzieć się, że jeżeli kolumny mają podkreślenie w nazwie jak np. animal_id to BeanPropertyRowMapper potrafi je zmapować na pola w konwencji „camelCase”.

Podsumowanie

Najważniejsza lekcja z tego artykułu to, że zawsze trzeba uważnie czytać dokumentację. Błędy wynikające z jej nieprzestrzegania nie powinny mieć odzwierciedlenia w testach integracyjnych. Nie ma żadnej wartości biznesowej w tym, że nie mamy wiedzy jak wykorzystywać dane narzędzie. Musimy się po prostu z tym obeznać, a testy tego typu powinny chronić główne części systemu.

Przy okazji zachęcam Cię do zapoznania się z całym procesem powstawania aplikacji AnimalShelter, który opisuję w tej serii artykułów.

Podziel się tym z innymi!

Może Ci się również spodoba

2 komentarze

  1. Rrrtrrtrtrtr pisze:

    Z tego powodu powstała klasa pakietowa ShelterAnimalRow, która reprezentuje już przyjęte zwierzątko do schroniska. Dzięki niej jesteśmy w stanie pobrać aktualną listę zwierząt i sprawdzić czy mamy jeszcze miejsce, aby przyjąć kolejne pupile.

    A co jak to zostanie wywołane równocześnie przez dwa żądania?

    • Cześć Rrrtrrtrtrtr! Dzięki, że napisałeś komentarz pod artykułem.

      Nie za bardzo wiem w jakim kontekście umieścić Twoje pytanie? 🙂 Podejrzewam, że zostanie utworzone wiele obiektów `ShelterAnimalRow`. Może też się zdarzyć, że jedno żądanie będzie musiało poczekać aż skończy drugie. Jest wiele możliwych odpowiedzi i szczerze nie za bardzo wiem jak mogę Ci pomóc. Mógłbyś rozszerzyć swoje pytanie? 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *