One to Many Relationships With Dexie | Task

Ole Ersoy
Mar - 05  -  7 min

Scenario

We have a Contact instance that has many PhoneNumber and EmailAddress AbstractEntity references.

We want a Dexie IndexedDB database and a CRUD API for performing operations on it with the class types that we are modeling.

Approach

Dependencies

Add dexie to either your Stackblitz dependencies or install it like this in your local project:

npm i -S dexie

Add nanoid is well. We will use it to generate global identifier strings for the entities.

npm i -S nanoid

Model

AbstractEntity

This entity makes it easy to perform equals comparisons and as well as general identification of all database instances both before and after persistance / generally throughout the lifetime of entity.

/**
 * Abstract entity model with `gid` property initialization
 * and `equals` method for entity comparisons.
 */
export abstract class AbstractEntity {
    constructor(public gid?: string) {
        gid ? (this.gid = gid) : (this.gid = cuid());
    }
    equals(e1: AbstractEntity, e2: AbstractEntity) {
        return e1.gid == e2.gid
    }
}

PhoneNumber

/*
 * The class helps with code completion
 *
 * Defines the interface of objects in the phone number table.
 */
export class PhoneNumber extends AbstractEntity {
    constructor(
        public contactId: string,
        public type: string,
        public phone: string,
        gid?: string) {
            super(gid)
   }
}

EmailAddress

/*
 * The class helps with code completion
 *
 * Defines the interface of objects in the phone number table.
 */
export class EmailAddress extends AbstractEntity {
    constructor(
        public contactId: string,
        public type: string,
        public email: string,
        gid?: string) {
            super(gid);
    }
}

Contact

Note that we are setting the enumerable attribute of the properties emails and phones to false . This tells Dexie that these properties should not be considered columns in the Contact table.

/*
 * Class mapped to the the contacts table in db.ts by the line:
 * db.contacts.mapToClass(Contact)
 */
export class Contact extends AbstractEntity {
  emails: EmailAddress[]
  phones: PhoneNumber[]

  constructor(
    public firstName: string,
    public lastName: string,
    gid?: string
  ) {
    super(gid)
    // Define navigation properties.
    // Making them non-enumerable will prevent them from being handled by indexedDB
    // when doing put() or add().
    Object.defineProperties(this, {
      emails: { value: [], enumerable: false, writable: true },
      phones: { value: [], enumerable: false, writable: true }
    });
  }
}

Database

We will define the database class in db.ts.

import Dexie from 'dexie';

import { EmailAddress, 
         PhoneNumber, 
         Contact } from './model';

export class AppDatabase extends Dexie {

    public contacts: Dexie.Table<Contact, string>
    public emails: Dexie.Table<EmailAddress, number>
    public phones: Dexie.Table<PhoneNumber, number>

    constructor() {

        super("ContactsDatabase")
        const db = this

        //
        // Define tables and indexes
        //
        db.version(1).stores({
            contacts: '&gid, firstName, lastName',
            emails: '&gid, contactId, type, email',
            phones: '&gid, contactId, type, phone',
        });

        // Let's physically map Contact class to contacts table.
        // This will make it possible to call loadEmailsAndPhones()
        // directly on retrieved database objects.
        db.contacts.mapToClass(Contact)
        db.emails.mapToClass(EmailAddress)
        db.phones.mapToClass(PhoneNumber)
    }
}
export const db = new AppDatabase()

Note that we are calling mapToClass to map table records to their corresponding class instance.

For example Contact is mapped like this:

db.contacts.mapToClass(Contact)

This allows us to define API methods like this:

/**
 * Read a contact
 */
export  async function readContact(db, contactGID:string):Promise<Contact> {
    return await db.contacts.get(contactGID)
}

And invoke readContact like this:

const c:Contact = await readContact(db, arnold.gid)

The tables and indexes are defined in the constructor call:
db.version(1).stores({
    contacts: '&gid, firstName, lastName',
    emails: '&gid, contactId, type, email',
    phones: '&gid, contactId, type, phone',
});

The & that prefixes gid means that gid is globally unique.

API

The API is defined in utilities.ts:

import { Contact, EmailAddress, PhoneNumber } from './model'
import cuid from 'cuid'

/**
 * Delete the entire database
 */
export async function deleteDatabase(db) {
  await db.delete()
}

/**
 * Open a  database
 */
export async function openDatabase(db) {
  await db.open()
}

/**
 * Clear all tables
 */
export async function clearAllTables(db) {
    await Promise.all(
      [db.contacts.clear(), 
       db.emails.clear(), 
       db.phones.clear()]);
}

/**
 * Read all contacts
 */
export async function readAllContacts(db) {
  return await db.contacts.toArray()
}

/**
 * Delete all contacts
 */
export async function deleteAllContact(db) {
  return await db.contacts.clear()
}

/**
 * Create a contact
 * 
 * Note that since the contact is guaranteed
 * to have a unique ID we are using `put` 
 * to update the databse.
 */
export  async function createContact(db, contact:Contact):Promise<string> {
  return await db.contacts.put(contact)
}

/**
 * Read a contact
 */
export  async function readContact(db, contactGID:string):Promise<Contact> {
  return await db.contacts.get(contactGID)
}

/**
 * Update contact
 */
export async function updateContact(db, contact:Contact) {
  return await db.contacts.put(contact)
}

/**
 * Delete contact
 */
export async function deleteContact(db, contact:Contact) {
  return await db.contacts.where('gid').equals(contact.gid).delete()
}

/**
 * Read all email addresses
 */
export async function readAllEmailAddresses(db) {
  return await db.emails.toArray()
}

/**
 * Delete all email addresses
 */
export async function deleteAllEmailAddresses(db) {
  return await db.emails.clear()
}

/**
 * Create email address record
 * 
 * Note that since the EmailAddress instance
 * is guaranteed
 * to have a unique ID we are using `put` 
 * to update the databse.
 */
export async function createEmailAddress(db, email:EmailAddress) {
  return await db.emails.put(email)
}

/**
 * Update an email address record
 */
export async function updateEmailAddress(db, email:EmailAddress) {
  return await db.emails.put(email)
}

/**
 * Delete contact
 */
export async function deleteEmail(db, email:EmailAddress) {
  await db.contacts.where('gid').equals(email.gid).delete()
}

/**
 * Read all phone number records
 */
export async function readAllPhoneNumbers(db) {
  return await db.phones.toArray()
}

/**
 * Delete all phone numbers
 */
export async function deleteAllPhoneNumbers(db) {
  await db.phones.clear()
}

/**
 * Create email address record
 */
export async function createPhoneNumber(db, phone:PhoneNumber) {
  return await db.phones.put(phone)
}

/**
 * Update the PhoneNumber record
 */
export async function updatePhoneNumber(db, phone:PhoneNumber) {
  await db.phones.put(phone)
}

/**
 * Deletre the phone number
 */
export async function deletePhoneNumber(db, phone:PhoneNumber) {
  await db.phones.where('gid').equals(phone.gid).delete()
}

/**
 * Load email records and 
 * update the corresponding ocntact fields.
 */
export async function loadContactEmails(contact, db) {
    contact.emails = 
    await db.emails.where('contactId').equals(contact.id).toArray()
}

/**
 * Load phone records and 
 * update the ocrresponding ocntact fields.
 */
export async function loadContactPhones(contact:Contact, db) {
    contact.phones = 
    await db.phones.where('contactId').equals(contact.gid).toArray()
}

/**
 * Load navgiation properties (Email and Phone records) and 
 * update the ocrresponding ocntact fields.
 */
export async function loadNavigationProperties(db, contact:Contact) {
    [contact.emails, contact.phones] = await Promise.all([
        db.emails.where('contactId').equals(contact.gid).toArray(),
        db.phones.where('contactId').equals(contact.gid).toArray()
    ]);
}

/**
 * Save a contact entity.  If email or phone records 
 * were removed from the contact, then these will also
 * be deleted from the database.
 */
export async function saveContact(db, contact:Contact) {
    return db.transaction('rw', db.contacts, db.emails, db.phones, async () => {
        
        // Add or update contact. If add, record contact.id.
        contact.gid = await db.contacts.put(contact);
        // Save all navigation properties (arrays of emails and phones)
        // Some may be new and some may be updates of existing objects.
        // put() will handle both cases.
        // (record the result keys from the put() operations into emailIds and phoneIds
        //  so that we can find local deletes)
        let [emailIds, phoneIds] = await Promise.all ([
            Promise.all(contact.emails.map(email => updateEmailAddress(db, email))),
            Promise.all(contact.phones.map(phone => updatePhoneNumber(db, phone)))
        ]);
                        
        // Was any email or phone number deleted from out navigation properties?
        // Delete any item in DB that reference us, but is not present
        // in our navigation properties:
        await Promise.all([
            db.emails.where('contactId').equals(contact.gid) // references us
                .and(email => emailIds.indexOf(email.id) === -1) // Not anymore in our array
                .delete(),
        
            db.phones.where('contactId').equals(contact.gid)
                .and(phone => phoneIds.indexOf(phone.id) === -1)
                .delete()
        ])
    });
}

Initialize and Test Database Operations

The script index.ts in the below Stackblitz demo performs a series of operations on our database.

Demo