Blog-Archiv

Mittwoch, 2. August 2023

JPA Criteria Query Examples with Subqueries

This article is about JPA database actions on an unidirectional many-to-many relation between two entity-types. You will find associated JPQL (Java Persistence Query Language) and Criteria-API queries here. Additionally my article about Criteria API may be helpful.

Entity Base Class

All annotations in subsequent classes are imported from javax.persistence and thus do not refer to any JPA provider (like Hibernate or EclipseLink).

Following is useful as base class for database entities with an required identity-attribute (Id):

 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
@MappedSuperclass
public abstract class BaseEntity
{
    @Id
    private String id = UUID.randomUUID().toString();

    public final String getId() {
        return id;
    }
    
    @Override
    public final boolean equals(Object o) {
        if (this == o)    // performance optimization
            return true;
        
        if (o == null || getClass() != o.getClass()) // exclude aliens
            return false;   // and one-to-one entities with same id
        
        return id.equals(((BaseEntity) o).id); // delegate equality to id
    }
    
    @Override
    public final int hashCode() {
        return id.hashCode();
    }
}

Having an Id value already at construction time solves the problems with entity-objects lost in hash containers (due to broken hashcode/equals contract when delegating to an ID field whose value is null after construction). With this class, we can delegate both hashCode() and equals() to the id field at any time.

Entity Classes

An individual can be member of several collectives, a collective can refer to several individuals.

Both of following entity types represents a database table, while the JPA provider (e.g. Hibernate EntityManager) creates a third table Collective_Individual, implementing the m:n relation between Individual and Collective.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
public class Individual extends BaseEntity
{
    private String name;
    
    public Individual() {
    }
    public Individual(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    
    @Override
    public String toString() {
        return getName();
    }
}

 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
@Entity
public class Collective extends BaseEntity
{
    private String name;

    @ManyToMany(cascade = CascadeType.ALL)
    private Set<Individual> individuals = new HashSet<>();
    
    public Collective() {
    }
    public Collective(String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    
    public Set<Individual> getIndividuals() {
        return individuals;
    }
    public void setIndividuals(Set<Individual> individuals) {
        this.individuals = individuals;
    }
    
    @Override
    public String toString() {
        return getName()+" "+getIndividuals();
    }
}

A collective holds a list of individuals, but individuals do not refer backwards with a list of collectives they are in. It may be illegal to cascade the deletion of a collective to the related individuals. Nevertheless the relation cascades with CascadeType.ALL, which is quite useful to avoid orphaned individuals. When JPA deletes a collective with this cascade-type, it will automatically delete its individuals, but only those that are not referenced by a still existing collective (see according test below).

Test Frame

Here is the test class frame, including test data. Please find the test's base classes (AbstractJpaTest, JpaUtil etc.) in my article about JPA Unit Tests across Providers and Database Products. Such a test can be run on several JPA providers, crossed with several database products. It does not need persistence.xml configuration. It also truncates all database tables after each test.

 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
public class IndividualToCollectiveTest extends AbstractJpaTest
{
    @Override
    protected List<Class<?>> getManagedClasses() {
        return List.of(Collective.class, Individual.class);
    }

    @Override
    protected PersistenceUnitInfo[] getPersistenceUnits(List<Class<?>> managedClasses)    {
        return new PersistenceUnitInfo[] {
            new HibernatePersistenceUnit(managedClasses)
        };
    }

    @Override
    protected Properties[] getDatabasePropertiesSets(String persistenceUnitName)  {
        return new Properties[] {
            new H2Properties(persistenceUnitName)
        };
    }
    
    private Individual leader1 = new Individual("Leader 1");
    private Individual deputy1 = new Individual("Deputy 1");
    private Individual sharedMember = new Individual("Shared Member");
    private Individual member21 = new Individual("Member 2/1");
    private Individual orphan = new Individual("Orphan");
    private Collective collective1 = new Collective("Collective 1");
    private Collective collective2 = new Collective("Collective 2");
    
    private void insertMembers(EntityManager entityManager) {
        collective1.getIndividuals().add(sharedMember);
        
        collective2.getIndividuals().add(sharedMember);
        collective2.getIndividuals().add(member21);
        
        JpaUtil.persistAll(new Object[] { collective1, collective2, orphan }, entityManager);
        
        final List<Collective> collectives = JpaUtil.findAll(Collective.class, entityManager);
        assertEquals(2, collectives.size());
        
        final List<Individual> individuals = JpaUtil.findAll(Individual.class, entityManager);
        assertEquals(3, individuals.size());
    }

    // insert tests and queries here ....

}

The getManagedClasses() implementation on line 4 lists all involved persistence classes, in deletion dependency order. (A collective must be deleted before the individuals it refers to.) This method, together with the methods on line 9 and 16, replaces persistence.xml.

There are not many but sufficient test data. We have two collectives, the first has a member "Shared Member" (individual) that also belongs to the second collective. The second collective has an additional member "Member 2/1" that does not belong to any other collective. There is also an "Orphan" individual that belongs to no collective at all.

Cascading Deletion Test

The following test removes "Collective 2" and asserts that "Shared Member" is still present afterwards, but "Member 2/1" has been deleted by cascading.

    @Test
    public void sharedPersonsShouldBeRetainedOnRemoveCascading() {
        executeForAll(entityManager -> {
            insertMembers(entityManager);
            
            // remove a collective cascading
            JpaUtil.remove(collective2, entityManager);
            
            final List<Collective> collectives = JpaUtil.findAll(Collective.class, entityManager);
            assertEquals(1, collectives.size());
            assertTrue(collectives.contains(collective1));
            
            final List<Individual> individuals = JpaUtil.findAll(Individual.class, entityManager);
            assertEquals(2, individuals.size());
            assertFalse(individuals.contains(member21)); // was just in deleted collective
            assertTrue(individuals.contains(sharedMember)); // still present because in both collectives
            assertTrue(individuals.contains(orphan)); // still present because in no collective
        });
    }

This behavior should happen due to the @ManyToMany(cascade = CascadeType.ALL) annotation on the Set individuals in class Collective.

Query Tests

Orphan Query

The next test calls a query that finds out orphans that are not referred to by any collective.

    @Test
    public void findOrphans() {
        executeForAll(entityManager -> {
            insertMembers(entityManager);
            
            List<Individual> orphans = queryOrphanedIndividuals(entityManager);
            System.err.println(orphans);
            assertEquals(1, orphans.size());
            assertTrue(orphans.contains(orphan));
        });
    }

Here is the query, in both JPQL and Criteria-API form. Mind that the JPA layer generated an artificial table Collective_Individual that has no Java class representation and implements the m:n relation between collective and individual.

        select i 
        from Individual i 
        where  not exists (
            select c.id
            from Collective c 
            inner join Collective_Individual c2i on c.id = c2i.Collective_id 
            inner join Individual i2c on c2i.individuals_id = i2c.id
            where i.id = i2c.id
         )
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    private List<Individual> queryOrphanedIndividuals(EntityManager entityManager) {
        final CriteriaBuilder builder = entityManager.getCriteriaBuilder();
        
        final CriteriaQuery<Individual> individualQuery = builder.createQuery(Individual.class);
        final Root<Individual> individualRoot = individualQuery.from(Individual.class);
        
        final Subquery<Collective> collectiveQuery = individualQuery.subquery(Collective.class);
        final Root<Collective> collectiveRoot = collectiveQuery.from(Collective.class);
        collectiveQuery.select(collectiveRoot);
        final Join<Collective,Individual> collectiveJoin = collectiveRoot.join("individuals"); // OneToMany property in Collective
        collectiveQuery.where(builder.equal(individualRoot.get("id"), collectiveJoin.get("id")));
        
        individualQuery.where(builder.not(builder.exists(collectiveQuery)));
        
        return entityManager.createQuery(individualQuery).getResultList();
    }

Criteria queries are not easy to read, but they are fully typed and their logic is reusable. Mind that the property names "individuals" and "id" on lines 10 and 11 should come from source code generated by some generator like the Maven artifact hibernate-jpamodelgen.

Membership Count Query

Following test queries individuals with no collective membership, then with one membership, then with two.

    @Test
    public void findIndividualsWithNumberOfMemberships() {
        executeForAll(entityManager -> {
            insertMembers(entityManager);
            
            List<Individual> orphans = queryIndividualsHavingNumberOfMemberships(entityManager, 0L);
            assertEquals(1, orphans.size());
            assertEquals(orphan, orphans.get(0));
            
            List<Individual> inOneCollective = queryIndividualsHavingNumberOfMemberships(entityManager, 1L);
            assertEquals(1, inOneCollective.size());
            assertTrue(inOneCollective.contains(member21));
            
            List<Individual> inTwoCollectives = queryIndividualsHavingNumberOfMemberships(entityManager, 2L);
            assertEquals(1, inTwoCollectives.size());
            assertTrue(inTwoCollectives.contains(sharedMember));
        });
    }

Following query delivers individuals that are in 0, 1, 2 or ... collectives. The number of memberships is passed as runtime-parameter to the query.

        select i
        from Individual i 
        where (
            select count(c.id) 
            from Collective c 
            inner join Collective_Individual c2i on c.id = c2i.Collective_id 
            inner join Individual i2c on c2i.individual_id = i2c.id 
            where i.id = i2c.id
        ) = :numberOfMemberships
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
    private List<Individual> queryIndividualsHavingNumberOfMemberships(EntityManager entityManager, Long numberOfMemberships) {
        final CriteriaBuilder builder = entityManager.getCriteriaBuilder();
        
        final CriteriaQuery<Individual> individualQuery = builder.createQuery(Individual.class);
        final Root<Individual> individualRoot = individualQuery.from(Individual.class);
        
        final Subquery<Long> collectiveCountQuery = individualQuery.subquery(Long.class); // is a count query
        final Root collectiveCountRoot = collectiveCountQuery.from(Collective.class);
        collectiveCountQuery.select(builder.count(collectiveCountRoot.get("id")));
        final Join collectiveJoin = collectiveCountRoot.join("individuals"); // ManyToMany property in Collective
        collectiveCountQuery.where(builder.equal(individualRoot.get("id"), collectiveJoin.get("id")));
        
        individualQuery.where(builder.equal(collectiveCountQuery, numberOfMemberships));
        
        return entityManager.createQuery(individualQuery).getResultList();
    }

Both queries are good examples of how to add nested sub-queries via Criteria-API. Mind that you do not need to explictly join the Collective_Individual table in the criteria-join on line 10!

I do not want to discuss every line of code here. Criteria queries are hard to write and hard to read. You need to understand the involved classes, and that Predicates (WHERE clause conditions) always come from the CriteriaBuilder.

Good luck:-)




Keine Kommentare: