Android Room: Modeling 1 to 0..1 relationships

Viewed 40

I’m currently writing an app that has a class Account and a class CreditcardDetails:

public class Account implements Serializable {

@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
private long id;

@NonNull
@ColumnInfo(name = "account_type")
private AccountType type;


public class CreditCardDetails implements Serializable {

@PrimaryKey(autoGenerate = true)
private long id;

@ColumnInfo(name = "credit_card_number")
private String creditCardNumber;

@ColumnInfo(name = "credit_card_type")
private CreditCardType creditCardType;

Whenever an account is of type CREDIT_CARD, I want the account to hold an instance of credit card details. I see several approaches to model this:

  1. Have a field in the account class for the credit card details object and annotate it with @Embedded. I don’t really like this approach since most accounts are not credit card accounts which would lead to many null fields in the table.
  2. Return a multimap. I’m not sure if this works with LiveData though.
  3. Create an intermediate class. This means additional boilerplate code, which I’m not too happy with.

What exactly is the proper mechanism to deal with nested entities and Room API?

3 Answers

That answer is that you don't want to have nested entities. Just add a field into the CreditCardDetails entity that says something like "accountId", which references the corresponding Account.

You can also change your model to make the structure "flat", meaning that you just have one class called Account, and have all the CC fields there. And if the account type is not CC, then those fields will just be empty

But if you really want to embed one entity inside the other, you can follow the example here

From a database aspect then I would suggest that the proper way is to utilise a relationship rather than nesting.

Here's a basic working Example, that not only caters for 1-0..1 (CreditCardDetails to Account) but caters for an Account to have 0..n credit cards.

  • Assuming that an account is a user as such then there would be a real possibility that an account could have multiple cards.

The example demonstrates all the permutations (accounts without any cards, cards with no accounts, accounts with one or multiple cards).

The ""boiler plate" isn't that much of an overhead.

So there are 3 main classes the 2 Entities (tables) Account and CreditCardDetails and a POJO for getting CreditCardDetailsWithParentAccount** :-

@Entity
public class Account implements Serializable {

    @PrimaryKey(/*autoGenerate = true */) /* inefficient as uses SQLite AUTOINCREMENT */
    @ColumnInfo(name = "id")
    private Long id; /* Long rather than long for more efficient auto generate */

    @NonNull
    @ColumnInfo(name = "account_type")
    private String/*AccountType changed to String for demo */ type ;

    Account() {}

    @Ignore
    Account( String type) {
        this.id = null;
        this.type = type;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    @NonNull
    public String getType() {
        return type;
    }

    public void setType(@NonNull String type) {
        this.type = type;
    }
}

and

@Entity
public class CreditCardDetails implements Serializable {

   @PrimaryKey(/*autoGenerate = true*/) /* same as for Account */
   private Long id;

   @ColumnInfo(name = "account_reference", index = true)
   private Long account_id; /* Long rather than Long allows potential for null for no account */

   @ColumnInfo(name = "credit_card_number")
   private String creditCardNumber;

   @ColumnInfo(name = "credit_card_type")
   private String /*CreditCardType change to String for demo  so no call for TypeConverter */ creditCardType;

   CreditCardDetails(){}
   @Ignore
   CreditCardDetails(String creditCardNumber, Long account_id,String creditCardType) {
      this.id = null;
      this.creditCardNumber = creditCardNumber;
      this.account_id = account_id;
      this.creditCardType = creditCardType;
   }

   public Long getId() {
      return id;
   }

   public void setId(Long id) {
      this.id = id;
   }

   public Long getAccount_id() {
      return account_id;
   }

   public void setAccount_id(Long account_id) {
      this.account_id = account_id;
   }

   public String getCreditCardNumber() {
      return creditCardNumber;
   }

   public void setCreditCardNumber(String creditCardNumber) {
      this.creditCardNumber = creditCardNumber;
   }

   public String getCreditCardType() {
      return creditCardType;
   }

   public void setCreditCardType(String creditCardType) {
      this.creditCardType = creditCardType;
   }
}

and the POJO

class CreditCardDetailsWithParentAccount {
   @Embedded
   private CreditCardDetails creditCardDetails;
   @Relation(
           entity = Account.class,
           entityColumn = "id",
           parentColumn = "account_reference"
   )
   private Account account;

   public Account getAccount() {
      return account;
   }

   public void setAccount(Account account) {
      this.account = account;
   }

   public CreditCardDetails getCreditCardDetails() {
      return creditCardDetails;
   }

   public void setCreditCardDetails(CreditCardDetails creditCardDetails) {
      this.creditCardDetails = creditCardDetails;
   }
}

For Data Access an @Dao annotated abstract class AllDao (could be an interface, but an abstract class allows methods with bodies so is more flexible BUT the methods have to be abstract) :-

@Dao
abstract class AllDao {

   @Insert(onConflict = OnConflictStrategy.IGNORE)
   abstract long insert(Account account);

   @Insert(onConflict = OnConflictStrategy.IGNORE)
   abstract long insert(CreditCardDetails creditCardDetails);

   @Query("SELECT * FROM account")
   abstract List<Account> getAllAccounts();
   @Query("SELECT * FROM creditCardDetails")
   abstract List<CreditCardDetails> getAllCreditCardDetails();
   @Transaction
   @Query("SELECT * FROM creditCardDetails")
   abstract List<CreditCardDetailsWithParentAccount> getAllCreditCardsDetailsWithAccount();

}

Putting it altogether from the Room perspective (for brevity/convenience allowing access from the main thread) an '@Database' annotated abstract class that extends RoomDatabase is the TheDatabase class:-

@Database(entities = {Account.class,CreditCardDetails.class}, exportSchema = false, version = 1)
abstract class TheDatabase extends RoomDatabase {
   abstract AllDao getAllDao();

   private volatile static TheDatabase instance;

   public static TheDatabase getInstance(Context context) {
      if (instance==null) {
         instance = Room.databaseBuilder(context,TheDatabase.class,"the_database.db")
                 .allowMainThreadQueries() /* for demo */
                 .build();
      }
      return instance;
   }
}

Finally to demonstrate MainActivity which inserts some accounts and credit card details and then extracts all the accounts, all the credit card details and then All the credit card details with the related account via the POJO:-

public class MainActivity extends AppCompatActivity {

    TheDatabase db;
    AllDao dao;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        db = TheDatabase.getInstance(this);
        dao = db.getAllDao();

        long a1id = dao.insert(new Account("Account001"));
        long a2id = dao.insert(new Account("Account002"));
        long a3id = dao.insert(new Account("Account003"));

        dao.insert(new CreditCardDetails("1234-1111-1111",a1id,"VISA"));
        dao.insert(new CreditCardDetails("1234-2222-2222",a2id,"MC"));
        dao.insert(new CreditCardDetails("1234-3333-3333",a2id,"AMEX"));
        dao.insert(new CreditCardDetails("1234-4444-4444",null,"NOWT"));

        for (Account a: dao.getAllAccounts()) {
            Log.d("DI_ACCOUNT","Account is " + a.getId() + " Type is " + a.getType());
        }
        for (CreditCardDetails cd: dao.getAllCreditCardDetails()) {
            Log.d("DI_CREDITCARD","Credit Card Number is " + cd.getCreditCardNumber() + " Type is " + cd.getCreditCardType() + "Account id is " + cd.getAccount_id());
        }
        for(CreditCardDetailsWithParentAccount cdwa: dao.getAllCreditCardsDetailsWithAccount()) {
            if (cdwa.getAccount() != null) {
                Log.d("DI_CD_WITH_A", "Credit Card Number is " + cdwa.getCreditCardDetails().getCreditCardNumber() + ".... Account Type is " + cdwa.getAccount().getType());
            } else {
                Log.d("DI_CD_WITH_NOA","Credit Card Number is " + cdwa.getCreditCardDetails().getCreditCardNumber() + ".... Account Type is NO PARENT ACCOUNT");
            }
        }
    }
}

When the above is run (for the first time (it's only intended to be run once)) then the output to the log is:-

2022-08-18 19:28:42.033 D/DI_ACCOUNT: Account is 1 Type is Account001
2022-08-18 19:28:42.033 D/DI_ACCOUNT: Account is 2 Type is Account002
2022-08-18 19:28:42.033 D/DI_ACCOUNT: Account is 3 Type is Account003

2022-08-18 19:28:42.034 D/DI_CREDITCARD: Credit Card Number is 1234-1111-1111 Type is VISAAccount id is 1
2022-08-18 19:28:42.034 D/DI_CREDITCARD: Credit Card Number is 1234-2222-2222 Type is MCAccount id is 2
2022-08-18 19:28:42.034 D/DI_CREDITCARD: Credit Card Number is 1234-3333-3333 Type is AMEXAccount id is 2
2022-08-18 19:28:42.034 D/DI_CREDITCARD: Credit Card Number is 1234-4444-4444 Type is NOWTAccount id is null

2022-08-18 19:28:42.041 D/DI_CD_WITH_A: Credit Card Number is 1234-1111-1111.... Account Type is Account001
2022-08-18 19:28:42.041 D/DI_CD_WITH_A: Credit Card Number is 1234-2222-2222.... Account Type is Account002
2022-08-18 19:28:42.041 D/DI_CD_WITH_A: Credit Card Number is 1234-3333-3333.... Account Type is Account002
2022-08-18 19:28:42.041 D/DI_CD_WITH_NOA: Credit Card Number is 1234-4444-4444.... Account Type is NO PARENT ACCOUNT


Nested Example

For the nested (embedded) example the following have been added or changed.

AccountV2 (added)

public class AccountV2 implements Serializable {

   //private Long id; Nested within CreditCardDetailsV2 so not needed */
   private String/*AccountType changed to String for demo */ type ;

   AccountV2() {}
   @Ignore
   AccountV2( String type) {
      this.type = type;
   }

   @NonNull
   public String getType() {
      return type;
   }
   public void setType(@NonNull String type) {
      this.type = type;
   }
}
  • NOT annotated with @Entity as is no longer a table

CreditDetailsV2

@Entity
public class CreditCardDetailsV2 implements Serializable {

   @PrimaryKey(/*autoGenerate = true*/) /* same as for Account */
   private Long id;
   @Embedded
   private AccountV2 account;
   @ColumnInfo(name = "credit_card_number")
   private String creditCardNumber;
   @ColumnInfo(name = "credit_card_type")
   private String /*CreditCardType change to String for demo  so no call for TypeConverter */ creditCardType;

   CreditCardDetailsV2(){}
   @Ignore
   CreditCardDetailsV2(String creditCardNumber, AccountV2 account,String creditCardType) {
      this.id = null;
      this.creditCardNumber = creditCardNumber;
      this.account = account;
      this.creditCardType = creditCardType;
   }

   public Long getId() {
      return id;
   }

   public void setId(Long id) {
      this.id = id;
   }


   public AccountV2 getAccount() {
      return account;
   }

   public void setAccount(AccountV2 account) {
      this.account = account;
   }

   public String getCreditCardNumber() {
      return creditCardNumber;
   }

   public void setCreditCardNumber(String creditCardNumber) {
      this.creditCardNumber = creditCardNumber;
   }

   public String getCreditCardType() {
      return creditCardType;
   }

   public void setCreditCardType(String creditCardType) {
      this.creditCardType = creditCardType;
   }
}
  • Note the POJO is not required as the AccountV2 is embedded (nested) within

AllDaoV2 (added just to keep the code separate could be combined with our even replace AllDao)

@Dao
interface AllDaoV2 {

 @Insert(onConflict = OnConflictStrategy.IGNORE)
 long insert(CreditCardDetailsV2 creditCardDetailsV2);
 @Query("SELECT * FROM creditCardDetailsV2")
 List<CreditCardDetailsV2> getAllCreditCardDetailsV2();
}
  • interface rather than abstract class to show both

TheDatabase (changed to allow co-existence of the methodologies)

@Database(entities = {Account.class,CreditCardDetails.class, CreditCardDetailsV2.class /*<<<<< ADDED for V2*/}, exportSchema = false, version = 1)
abstract class TheDatabase extends RoomDatabase {
   abstract AllDao getAllDao();
   abstract AllDaoV2 getAllDaoV2(); /* ADDED for V2 */

   private volatile static TheDatabase instance;

   public static TheDatabase getInstance(Context context) {
      if (instance==null) {
         instance = Room.databaseBuilder(context,TheDatabase.class,"the_database.db")
                 .allowMainThreadQueries() /* for demo */
                 .build();
      }
      return instance;
   }
}

Finally MainActivity (changed to demo both methodologies using very much the equivalent data for each method):-

public class MainActivity extends AppCompatActivity {

    TheDatabase db;
    AllDao dao;
    AllDaoV2 daoV2; /* NEW for V2 */

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        db = TheDatabase.getInstance(this);
        dao = db.getAllDao();
        daoV2 = db.getAllDaoV2(); /* NEW for V2 */

        long a1id = dao.insert(new Account("Account001"));
        long a2id = dao.insert(new Account("Account002"));
        long a3id = dao.insert(new Account("Account003"));

        dao.insert(new CreditCardDetails("1234-1111-1111",a1id,"VISA"));
        dao.insert(new CreditCardDetails("1234-2222-2222",a2id,"MC"));
        dao.insert(new CreditCardDetails("1234-3333-3333",a2id,"AMEX"));
        dao.insert(new CreditCardDetails("1234-4444-4444",null,"NOWT"));

        for (Account a: dao.getAllAccounts()) {
            Log.d("DI_ACCOUNT","Account is " + a.getId() + " Type is " + a.getType());
        }
        for (CreditCardDetails cd: dao.getAllCreditCardDetails()) {
            Log.d("DI_CREDITCARD","Credit Card Number is " + cd.getCreditCardNumber() + " Type is " + cd.getCreditCardType() + "Account id is " + cd.getAccount_id());
        }
        for(CreditCardDetailsWithParentAccount cdwa: dao.getAllCreditCardsDetailsWithAccount()) {
            if (cdwa.getAccount() != null) {
                Log.d("DI_CD_WITH_A", "Credit Card Number is " + cdwa.getCreditCardDetails().getCreditCardNumber() + ".... Account Type is " + cdwa.getAccount().getType());
            } else {
                Log.d("DI_CD_WITH_NOA","Credit Card Number is " + cdwa.getCreditCardDetails().getCreditCardNumber() + ".... Account Type is NO PARENT ACCOUNT");
            }
        }


        /* NEW for V2 Equivalent of the initial data*/

        daoV2.insert(new CreditCardDetailsV2("1234-1111-1111",new AccountV2("Account0001"),"VISA"));
        daoV2.insert(new CreditCardDetailsV2("1234-2222-2222",new AccountV2("Account0002"),"MC"));
        daoV2.insert(new CreditCardDetailsV2("1234-3333-3333", new AccountV2("Account0002"),"AMEX"));
        daoV2.insert(new CreditCardDetailsV2("1234-4444-4444", null,"NOWT"));

        for (CreditCardDetailsV2 cdv2: daoV2.getAllCreditCardDetailsV2()) {
            if (cdv2.getAccount() != null) {
                Log.d("DI_CD_V2", "Credit Card Number is " + cdv2.getCreditCardNumber() + ".... Account is " + cdv2.getAccount().getType());
            } else {
                Log.d("DI_CD_V2_NOA", "Credit Card Number is " + cdv2.getCreditCardNumber() + ".... NO NESTED ACCOUNT");
            }
        }
    }
}

When run (after uninstalling) The log additionally contains (i.e. the output relevant to V2) :-

2022-08-18 20:11:54.464 D/DI_CD_V2: Credit Card Number is 1234-1111-1111.... Account is Account0001
2022-08-18 20:11:54.464 D/DI_CD_V2: Credit Card Number is 1234-2222-2222.... Account is Account0002
2022-08-18 20:11:54.464 D/DI_CD_V2: Credit Card Number is 1234-3333-3333.... Account is Account0002
2022-08-18 20:11:54.464 D/DI_CD_V2_NOA: Credit Card Number is 1234-4444-4444.... NO NESTED ACCOUNT

From a database perspective, the data is now not normalised that is the Account0002, as it has 2 CreditCards, is repeated and thus the same data is stored twice, probably wasting space, whilst in the first example the key (reference/map/relation) only is repeated, which will at most consume 8 bytes (well 16 or so as the key/reference was indexed.

Note that Embedding is basically just copying the fields/members from the Embedded class into the class so each row has additional columns.

As both versions/methods were combined the difference can easily be seen by using App Inspection which reveals :-

enter image description here

enter image description here

enter image description here

  • Note the encircled field/member anotherField can be ignored (it was added to test something else out)

  • The code I have provided does not use autogenerate = true, the code does briefly comment about using AUTOINCREMENT, which is inefficient. This link explains why AUTOINCREMENT is inefficient https://sqlite.org/autoinc.html

I did end up solving this problem by using a multimap with LiveData. This query in the AccountDAO returns a map of accounts and, if available, connected CreditCardDetails-instances:

@Query("SELECT * FROM account LEFT JOIN credit_card_details ON account.id = credit_card_details.account_id ORDER BY list_position ASC")
public abstract LiveData<Map<Account, CreditCardDetails>> getAccountsWithDetailsMap();

In the AccountRepository, through observing the LiveData-instance, I connected accounts and CreditCardDetails and put the result into a MutableLiveData-List:

        this.accountDao.getAllSorted().observeForever(accountCreditCardDetailsMap -> {
        List<Account> accountList = new ArrayList<>();

        for (Account account : accountCreditCardDetailsMap.keySet()) {

            if (account.isCreditCardAccount()) {
                CreditCardDetails creditCardDetails = accountCreditCardDetailsMap.get(account);
                account.setCreditCardDetails(creditCardDetails);
            }
            accountList.add(account);
        }

        mutableLiveDataAccountList.postValue(accountList);
    });

A class that needs the list of accounts can now observe it through this method in the repository:

    public LiveData<List<Account>> getAll() {
    return mutableLiveDataAccountList;
}
Related