Introduction
DexieJS is a mind blowingly well designed API wrapper for IndexedDB
:
Play with in in the browser and learn:
- Entity Modeling
- Relational design
Await/async
semanticsPromise
semantics
All while creating a brilliant local storage solution all at the same time.
Yes it’s that good! The people that created it are even better. You know Swedes make good stuff! Volvo, Ikea, Dexie, and the Swedish Chef — Need we say more?
You can work with Dexie in plain Javascript but here we will be using Typescript.
Playground
Console
In order to be able to see what Dexie is doing we will use the Dexie Typescript console implemented like this within within our Stackblitz index.ts
:
import Dexie from 'dexie';
import { Console } from './console';
import { db } from './db';
import { Contact } from './Contact';
const console = new Console();
// Test it:
console.log("Hello Dexie Lovers!");
console.log("==================\n");
Now when we do console.log
it will log directly to the Stackblitz rendering pane and we wont have to open the console to see logging statements.
Model
We will store contacts, phone numbers, and emails.
The Contact
instances have a One to Many relationship with the IEmailAddress
and IPhoneNumber
instances, and thus we have a contactId
field on these instances such that we can look them up using the id
property on our Contact
instance.
Below is the content of our model.ts
file:
/* Just for code completion and compilation - defines
* the interface of objects stored in the emails table.
*/
export interface IEmailAddress {
id?: number;
contactId: number;
type: string;
email: string;
}
/* Just for code completion and compilation - defines
* the interface of objects stored in the phones table.
*/
export interface IPhoneNumber {
id?: number;
contactId: number;
type: string;
phone: string;
}
/* This is a 'physical' class that is mapped to
* the contacts table. We can have methods on it that
* we could call on retrieved database objects.
*/
export class Contact {
id: number;
firstName: string;
lastName: string;
emails: IEmailAddress[];
phones: IPhoneNumber[];
constructor(first: string, last: string, id?:number) {
this.firstName = first;
this.lastName = last;
if (id) this.id = id;
// 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 }
});
}
}
Methods containing references to the database are contained in a utility for performing database operations, instead of being located on the model classes or interfaces themselves.
Also note how Object.defineProperties
is used to avoid double saving IEmailAddress
and IPhoneNumber
instances when the Contact
instance is saved or updated.
We’ll have a look at these next.
Database Utilities
These utilities are used to save contacts and load navigation properties (emails and phone records):
/**
* Load navgiation properties (Email and Phone records) and
* update the corresponding contact fields.
*/
export async function loadNavigationProperties(contact, db) {
[contact.emails, contact.phones] = await Promise.all([
db.emails.where('contactId').equals(this.id).toArray(),
db.phones.where('contactId').equals(this.id).toArray()
]);
}
/**
* 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 corresponding contact fields.
*/
export async function loadContactPhones(contact, db) {
contact.phones =
await db.phones.where('contactId').equals(contact.id).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 save(contact, db) {
return db.transaction('rw', db.contacts, db.emails, db.phones, async() => {
// Add or update contact. If add, record contact.id.
contact.id = 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 => db.emails.put(email))),
Promise.all(contact.phones.map(phone => db.phones.put(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.id) // references us
.and(email => emailIds.indexOf(email.id) === -1) // Not anymore in our array
.delete(),
db.phones.where('contactId').equals(contact.id)
.and(phone => phoneIds.indexOf(phone.id) === -1)
.delete()
]);
});
}
Database
The database will be defined in the db.ts file (The code contains elaboration comments from the Dexie repository implementation):
import Dexie from 'dexie';
import {IEmailAddress, IPhoneNumber, Contact} from './model';
export class AppDatabase extends Dexie {
public contacts: Dexie.Table<Contact, number>;
public emails: Dexie.Table<IEmailAddress, number>;
public phones: Dexie.Table<IPhoneNumber, number>;
constructor() {
super("ContactsDatabase");
var db = this;
//
// Define tables and indexes
//
db.version(1).stores({
contacts: '++id, firstName, lastName',
emails: '++id, contactId, type, email',
phones: '++id, contactId, type, phone',
});
db.contacts.mapToClass(Contact);
}
}
export var db = new AppDatabase();
Also the table properties ( contacts, emails, phones) are public
such that we can access these properties in our utilities.ts
file.
Application Script
Time to use the Dexi API:
// Import stylesheets
import './style.css';
import Dexie from 'dexie';
import { Console } from './console';
import { db} from './db';
import { Contact} from './model';
import { loadNavigationProperties, loadContactEmails, loadContactPhones, saveContact } from './utilities';
// Write TypeScript code!
const appDiv: HTMLElement = document.getElementById('app');
appDiv.innerHTML = `<h1>Dexie Lovers TypeScript Demo</h1>
<div id="consoleArea"></div>`;
const console = new Console();
// ===================================
// Bootstrapping
// Add a console element
// ===================================
document.getElementById('consoleArea').appendChild(console.textarea);
// Test it:
console.log("Hello Dexie Lovers!");
console.log("==================\n");
async function test() {
// Initialize our Console widget - it will log browser window.
try {
//
// Let's clear and re-seed the database:
//
console.log("Clearing database...");
//await db.delete();
//await db.open();
await Promise.all([db.contacts.clear(), db.emails.clear(), db.phones.clear()]);
await haveSomeFun();
} catch (ex) {
console.error(ex);
}
}
test();
async function haveSomeFun() {
//
// Seed Database
//
console.log("Seeding database with some contacts...");
await db.transaction('rw', db.contacts, db.emails, db.phones, async function () {
// Populate a contact
let arnoldId = await db.contacts.add(new Contact('Arnold', 'Fitzgerald'));
// Populate some emails and phone numbers for the contact
db.emails.add({ contactId: arnoldId, type: 'home', email: 'arnold@email.com' });
db.emails.add({ contactId: arnoldId, type: 'work', email: 'arnold@abc.com' });
db.phones.add({ contactId: arnoldId, type: 'home', phone: '12345678' });
db.phones.add({ contactId: arnoldId, type: 'work', phone: '987654321' });
// ... and another one...
let adamId = await db.contacts.add(new Contact('Adam', 'Tensta'));
// Populate some emails and phone numbers for the contact
db.emails.add({ contactId: adamId, type: 'home', email: 'adam@tensta.se' });
db.phones.add({ contactId: adamId, type: 'work', phone: '88888888' });
});
//
// For fun - add a phone number to Adam
//
// Now, just to examplify how to use the save() method as an alternative
// to db.phones.add(), we will add yet another phone number
// to an existing contact and then re-save it:
console.log("Playing a little: adding another phone entry for Adam Tensta...");
let adam = await db.contacts.orderBy('lastName').last();
console.log(`Found contact: ${adam.firstName} ${adam.lastName} (id: ${adam.id})`);
// To add another phone number to adam, the straight forward way would be this:
await db.phones.add({contactId: adam.id, type: "custom", phone: "+46 7777777"});
// But now let's do that same thing by manipulating navigation property instead:
// Load emails and phones navigation properties
await loadNavigationProperties(adam, db);
// Now, just push another phone number to adam.phones navigation property:
adam.phones.push({
contactId: adam.id,
type: 'custom',
phone: '112'
});
// And just save adam:
console.log("Saving contact");
await saveContact(adam, db);
// Now, print out all contacts
console.log("Now dumping some contacts to console:");
await printContacts();
}
async function printContacts() {
// Now we're gonna list all contacts starting with letter 'A','B' or 'C'
// and print them out.
// For each contact, also resolve the navigation properties.
// For atomicity and speed, use a single transaction for the
// queries to make:
let contacts = await db.transaction('r', [db.contacts, db.phones, db.emails], async()=>{
// Query some contacts
let contacts = await db.contacts
.where('firstName').startsWithAnyOfIgnoreCase('a','b','c')
.sortBy('id');
// Resolve array properties 'emails' and 'phones'
// on each and every contact:
await Promise.all (contacts.map(contact => loadNavigationProperties(contact, db)));
return contacts;
});
// Print result
console.log("Database contains the following contacts:");
contacts.forEach(contact => {
console.log(contact.id + ". " + contact.firstName + " " + contact.lastName);
console.log(" Phone numbers: ");
contact.phones.forEach(phone => {
console.log(" " + phone.phone + "(" + phone.type + ")");
});
console.log(" Emails: ");
contact.emails.forEach(email => {
console.log(" " + email.email + "(" + email.type + ")");
});
});
}