Embeddable objects in JPA

In some situations @Entity annotation can be useless and we can still need to use Java objects as objects managed by JPA provider. In these cases we can use embeddable objects, representing by @Embedded and @Embeddable annotations.

In the begin of this article we'll try to explain the situations when we can need to skip @Entity and use embeddable objects. After this theoretical part, we'll write and comment some test cases to illustrate how embeddable objects are supposed to work.

Embeddable object in JPA

A good case to understand the purpose of embeddable objects is a case of e-commerce store having very good written SEO content. SEO content which is stored in pages table as columns seo_title, seo_description and seo_keywords. One natural way to represent SEO as Java object could be 3 String fields of Page entity class. However, there're another manner to do that with embeddable objects. We can crate a SeoConfiguration object annotated with @Embeddable annotation and inject it into Page entity class as a field annotated with @Embedded.

Embeddable objects help to keep logical separation between objects. They also help to maintain the code and to make it evolve quickly and safely (it's simpler to change behavior of one common method for all objects instead of changing the behavior in each entity using SEO). We'll see this SEO example in the second part of the article.

So as we could deduce after discovering the sample use case, two "embed-something" annotations are used in two different contexts. @Embeddable one is used to define a class which can be injected into entities. @Embedded one is used in entities to point out which field should be considered as injected object.

Another kind of embeddable objects are primary keys. Sometimes your table's primary key can be composed by two values. Evidently, this composite key can't be represented by Java's primitive types (long, int...) because 11 it's not the same as 1-1. In this situation, another embeddable objects comes to rescue the situation. This object needs to be annotated with @EmbeddedId. As you can suppose, it represents embeddable objects being used as primary key composed by two or more values.

Example of embeddable objects use in JPA

First, let's take a look on sample embeddable object definition:

show mapping for embeddable object

And some test cases to illustrate that:

/**
 * Tests for entities with {@link javax.persistence.Embedded} - {@link javax.persistence.Embeddable} annotations.
 *
 * Expected values before launching the test:
 * <pre>
 * mysql> select * from category;
 * +----+-------+--------------------------+---------------------------------------------------------------------+---------------------------+
 * | id | name  | seo_title                | seo_desc                                                            | seo_keywords              |
 * +----+-------+--------------------------+---------------------------------------------------------------------+---------------------------+
 * |  1 | food  | Best food only with us   | You want to buy some great food ? Order it in our store             | great food                |
 * |  2 | drink | Best drinks only with us | You want to buy and drink some great drinks ? Order it in our store | great drinks, extra water |
 * +----+-------+--------------------------+---------------------------------------------------------------------+---------------------------+
 * 2 rows in set (0.00 sec)
 *
 * mysql> select name, meta_title, meta_description, meta_keywords from product;
 * +--------------+-----------------------------------------+--------------------------------------------+------------------------------+
 * | name         | meta_title                              | meta_description                           | meta_keywords                |
 * +--------------+-----------------------------------------+--------------------------------------------+------------------------------+
 * | cereal       | The best cereal only in our store       | Cereals and other food in our store        | cereals, breakfast food      |
 * | bread        | The best bread only in our store        | Bread and other food in our store          | bread, breakfast food        |
 * | water        | The best water only in our store        | Water and other drinks in our store        | water, drinks                |
 * | milk         | The best milk only in our store         | Milk and other drinks in our store         | milk, drinks                 |
 * | orange juice | The best orange juice only in our store | Orange juice and other drinks in our store | orange juice, natural drinks |
 * +--------------+-----------------------------------------+--------------------------------------------+------------------------------+
 * 5 rows in set (0.00 sec)
 * 
 * mysql> select * from user_visit;
 * +--------+-----------------+------------+
 * | number | visit_timestamp | visit_time |
 * +--------+-----------------+------------+
 * |      3 |      1417682003 |         50 |
 * |     10 |      1417682033 |          1 |
 * |     30 |      1417682045 |          3 |
 * |     40 |      1417682053 |          4 |
 * +--------+-----------------+------------+
 * </pre>
 *
 * @author Bartosz Konieczny
 */
public class EmbeddedEmbeddableTest extends AbstractJpaTester {

  @Test
  public void testReadingCategories() {
    Query query = entityManager.createQuery("SELECT c FROM Category c");
    List<Category> categories = query.getResultList();
    compareSeo("title", "Best food only with us",
      categories.get(0).getSeo().getTitle());
    compareSeo("description", "You want to buy some great food ? Order it in our store",
      categories.get(0).getSeo().getDescription());
    compareSeo("keywords", "great food",
      categories.get(0).getSeo().getKeywords());
    compareSeo("title", "Best drinks only with us",
      categories.get(1).getSeo().getTitle());
    compareSeo("description", "You want to buy and drink some great drinks ? Order it in our store",
      categories.get(1).getSeo().getDescription());
    compareSeo("keywords", "great drinks, extra water",
      categories.get(1).getSeo().getKeywords());
    // MySQL query to select
    // category0_.id as id1_4_, category0_.name as name2_4_, category0_.seo_desc as seo_desc3_4_,
    // category0_.seo_keywords as seo_keyw4_4_, category0_.seo_title as seo_titl5_4_ from category category0_
    // where category0_.id=3
  }

  @Test
  public void testReadingProduct() {
    String[][] expected = new String[5][];
    expected[0] = new String[] {"The best cereal only in our store", "Cereals and other food in our store", "cereals, breakfast food"};
    expected[1] = new String[] {"The best bread only in our store", "Bread and other food in our store", "bread, breakfast food"};
    expected[2] = new String[] {"The best water only in our store", "Water and other drinks in our store", "water, drinks"};
    expected[3] = new String[] {"The best milk only in our store", "Milk and other drinks in our store", "milk, drinks"};
    expected[4] = new String[] {"The best orange juice only in our store", "Orange juice and other drinks in our store", "orange juice, natural drinks"};

    Query query = entityManager.createQuery("SELECT p FROM Product p");
    List<Product> products = query.getResultList();
    for (int i = 0; i < 5; i++) {
      String[] expectedConfig = expected[i];
      Product product = products.get(i);
      compareSeo("title", expectedConfig[0], product.getSeo().getTitle());
      compareSeo("description", expectedConfig[1], product.getSeo().getDescription());
      compareSeo("keywords", expectedConfig[2], product.getSeo().getKeywords());
    }
  }

  @Test
  public void testAddCategory() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Seo seo = new Seo();
      seo.setTitle("seo_title");
      seo.setDescription("seo_desc");
      seo.setKeywords("seo_keywords");

      Category category = new Category();
      category.setName("Temporary category");
      category.setSeo(seo);
      entityManager.persist(category);

      Query query = entityManager.createQuery("SELECT c FROM Category c WHERE c.id = :id");
      query.setParameter("id", category.getId());
      Category dbCategory = (Category) query.getSingleResult();

      assertEquals("Bad title was inserted", "seo_title", dbCategory.getSeo().getTitle());
      assertEquals("Bad description was inserted", "seo_desc", dbCategory.getSeo().getDescription());
      assertEquals("Bad keywords were inserted", "seo_keywords", dbCategory.getSeo().getKeywords());
    } finally {
      transaction.rollback();
    }
  }

  @Test
  public void testAddProduct() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Seo seo = new Seo();
      seo.setTitle("seo_title");
      seo.setDescription("seo_desc");
      seo.setKeywords("seo_keywords");

      Seo seoCat = new Seo();
      seoCat.setTitle("seo_title_cat");
      seoCat.setDescription("seo_desc_cat");
      seoCat.setKeywords("seo_keywords_cat");

      Category category = new Category();
      category.setName("tmp");
      category.setSeo(seoCat);
      entityManager.persist(category);

      Product product = new Product();
      product.setCategory(category);
      product.setName("Temporary product");
      product.setPrice(39.99d);
      product.setSeo(seo);
      entityManager.persist(product);

      Query query = entityManager.createQuery("SELECT p FROM Product p WHERE p.id = :id");
      query.setParameter("id", product.getId());
      Product dbProduct = (Product) query.getSingleResult();

      assertEquals("Bad title was inserted", "seo_title", dbProduct.getSeo().getTitle());
      assertEquals("Bad description was inserted", "seo_desc", dbProduct.getSeo().getDescription());
      assertEquals("Bad keywords were inserted", "seo_keywords", dbProduct.getSeo().getKeywords());
    } finally {
      transaction.rollback();
    }
  }

  private void compareSeo(String property, String expected, String toCompare) {
      assertEquals("Bad value was read for "+property, expected, toCompare);
  }

}

Example of embeddable id in JPA

And this is an example of embeddable id definition which is not different from classical embeddable object definition:

@Embeddable
public class UserVisitPk implements Serializable {
  private int visitNumber;
  private int visitTimestamp;

  public UserVisitPk() {
    // leave empty for compilation reasons
  }

  public UserVisitPk(int visitNumber, int visitTimestamp) {
    this.visitNumber = visitNumber;
    this.visitTimestamp = visitTimestamp;
  }

  @Column(name = "number")
  public int getVisitNumber() {
    return this.visitNumber;
  }

  @Column(name = "visit_timestamp")
  public int getVisitTimestamp() {
    return this.visitTimestamp;
  }

  public void setVisitNumber(int visitNumber) {
    this.visitNumber = visitNumber;
  }

  public void setVisitTimestamp(int visitTimestamp) {
    this.visitTimestamp = visitTimestamp;
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(this).add("visit number", visitNumber).add("visit timestamp",
      visitTimestamp).toString();
  }
}

And the test case:

  @Test
  public void testEmbeddableId() {
    EntityTransaction transaction = entityManager.getTransaction();
    try {
      transaction.begin();
      Query query = entityManager.createQuery("SELECT uv FROM UserVisit uv");
      List<UserVisit> visits = query.getResultList();
      assertEquals("4 visits should be found in database", 4, visits.size());
      UserVisitHelper[] expected = new UserVisitHelper[] {
        new UserVisitHelper(3, 1417682003, 50),
        new UserVisitHelper(10, 1417682033, 1),
        new UserVisitHelper(30, 1417682045, 3),
        new UserVisitHelper(40, 1417682053, 4)
      };
      int matched = 0;
      for (UserVisitHelper helper : expected) {
        for (UserVisit visit : visits) {
          if (visit.getUserVisitPk().getVisitNumber() == helper.number) {
            assertEquals("Bad timestamp was generated in composed PK", helper.timestamp,
              visit.getUserVisitPk().getVisitTimestamp());
            assertEquals("Bad row was matched by PK", helper.visitTime, visit.getVisitTime());
            matched++;
          }
        }
      }
      assertEquals("All rows should match with expected array", 4, matched);

      int time = (int) new Date().getTime();
      UserVisitPk pk = new UserVisitPk(100, time);
      UserVisit newVisit = new UserVisit();
      newVisit.setUserVisitPk(pk);
      newVisit.setVisitTime(100);
      entityManager.persist(newVisit);

      Query newVisitQuery = entityManager.createQuery("SELECT uv FROM UserVisit uv WHERE uv.userVisitPk = :pk");
      newVisitQuery.setParameter("pk", new UserVisitPk(100, time));
      UserVisit foundVisit = (UserVisit) newVisitQuery.getSingleResult();
      assertEquals("Bad row was found in SELECT by composed PK WHERE clause", newVisit, foundVisit);

      newVisitQuery = entityManager.createQuery("SELECT uv FROM UserVisit uv WHERE uv.userVisitPk.visitNumber = :nr");
      newVisitQuery.setParameter("nr", 100);
      foundVisit = (UserVisit) newVisitQuery.getSingleResult();
      assertEquals("Bad row was found in SELECT by composed PK value (visitNumber) in WHERE clause", newVisit, foundVisit);
    } finally {
      transaction.rollback();
    }
  }

  private static class UserVisitHelper {
    private int number;
    private int timestamp;
    private int visitTime;

    public UserVisitHelper(int n, int t, int v) {
      this.number = n;
      this.timestamp = t;
      this.visitTime = v;
    }
  }

This article shows the utility of embeddable objects in the situations needing to represent several table columns as a separate Java object. Embeddable can be also used to manage primary keys composed by two or more columns. According to the situation, this type of object can be injected with @Embedded or @EmbeddedId annotation. But it's always defined with single one annotation, @Embeddable.


If you liked it, you should read:

📚 Newsletter Get new posts, recommended reading and other exclusive information every week. SPAM free - no 3rd party ads, only the information about waitingforcode!