spring-data-jpaspring-data

@MappedSuperclass being assigned to a @OneToMany spring-data


Im working on an app to track customer accounts. Each customer can have multiple accounts. The challenges is that the account object is polymorphic. There is an abstract class Account with a 2-3 methods, and then there are currently 2 sub-types SavingsAccount and CreditAccount.

What I have tried is to have the Account object use the @MappedSuperclass annotation, and then each concrete class has the @Entity annotations as normal.

The problem seems to be the mapping my Customer object

    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
    @JoinColumn(name="customer_id", referencedColumnName = "id")
    List<Account> accounts = new ArrayList<>();

When I use that I get a warning from Spring in my IDE,but at runtime I get this error.

com.example.models.Customer.accounts' targets the type 'com.example.models.Account' which is not an '@Entity' type

Is there no way to have a @OneToMany mapping against a list of polymorphic objects? Even if they were stored in the same table?


Solution

  • @MappedSuperclass is used for reusing common properties across @Entity-annotated classes and does not apply directly to your current case. However, a correct usage example for @MappedSuperclass in your case would be:

    @MappedSuperclass
    public abstract class MyMappedSuperclass {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        // getter/setter
    }
    

    and let Customer inherit MyMappedSuperclass.

    You need to annotate your base Account class with @Entity and @Inheritance. This enables polymorphic queries, where you can query the base class and retrieve all its subclasses. If using a single table for all subclasses, the Account class can look like this:

    import jakarta.persistence.*;
    
    @Entity
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "account_type")
    public abstract class Account {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @ManyToOne
        @JoinColumn(name = "customer_id", nullable = false)
        private Customer customer;
    
        // getters and setters
    }
    

    Your sub-classes will then look like this

    import jakarta.persistence.DiscriminatorValue;
    
    @DiscriminatorValue("SAVINGS")
    public class SavingsAccount extends Account {
    }
    
    @DiscriminatorValue("CREDIT")
    public class CreditAccount extends Account {
    }
    

    I verified the above code with this Customer implementation:

    import jakarta.persistence.*;
    
    import java.util.ArrayList;
    import java.util.List;
    
    @Entity
    public class Customer {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL)
        private List<Account> accounts = new ArrayList<>();
    
        public void addAccount(Account account) {
            account.setCustomer(this);
            accounts.add(account);
        }
    
        // getters/setters
    }
    

    CustomerRepository

    import org.springframework.data.repository.CrudRepository;
    
    public interface CustomerRepository extends CrudRepository<Customer, Long> {
    }
    

    Flyway migration (Postgresql)

    CREATE TABLE customer
    (
        id SERIAL PRIMARY KEY
    );
    
    CREATE TABLE account
    (
        id           SERIAL PRIMARY KEY,
        account_type VARCHAR(16) NOT NULL,
        customer_id  INT         NOT NULL,
        CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES customer (id)
    );
    

    PostgresTestContainerConfig

    import org.springframework.boot.test.context.TestConfiguration;
    import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
    import org.springframework.context.annotation.Bean;
    import org.testcontainers.containers.PostgreSQLContainer;
    
    @TestConfiguration
    public class PostgresTestContainerConfig {
    
        @Bean
        @ServiceConnection
        public PostgreSQLContainer<?> postgreSQLContainer() {
            return new PostgreSQLContainer<>("postgres:15-alpine");
        }
    }
    

    Test

    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
    import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
    import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
    import org.springframework.context.annotation.Import;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    @DataJpaTest
    @AutoConfigureTestDatabase
    @Import(no.vicx.database.PostgresTestContainerConfig.class)
    class CustomerRepositoryTest {
    
        @Autowired
        TestEntityManager entityManager;
    
        @Autowired
        CustomerRepository sut;
    
        @Test
        void testInserts() {
            Customer customer = new Customer();
            customer.addAccount(new SavingsAccount());
            customer.addAccount(new CreditAccount());
    
            sut.save(customer);
    
            assertEquals(2, getAccountCountInDb());
    
            var customerInDb = entityManager.find(Customer.class, customer.getId());
    
            assertInstanceOf(SavingsAccount.class, customerInDb.getAccounts().getFirst());
            assertInstanceOf(CreditAccount.class, customerInDb.getAccounts().getLast());
        }
    
        long getAccountCountInDb() {
            return (long) entityManager.getEntityManager()
                    .createQuery("SELECT COUNT(1) FROM Account ")
                    .getSingleResult();
        }
    }