-
-
Notifications
You must be signed in to change notification settings - Fork 642
Design
Dexie is both a class and a namespace. An instance of Dexie will represents a database connection. As namespace, it is used as an export area for functions, utilities and classes. In a simple HTML browser environment this means that including "Dexie.js" will only add one property to window: window.Dexie. If you are utilizing a module environment like require.js, Dexie will be what you get when requiring it. Here's an example how to use Dexie once you've included it:
// Create your instance
var db = new Dexie("MyDatabase");
// Define your schema
db.version(1).stores({
myObjectStore1: "primaryKey, index1, index2, ...",
myObjectStore2: "primaryKey, index1, index2, ...",
...
});
// Open the database
db.open();
Dexie, as its backend indexedDB implementation, is an asynchronous database, meaning that any operation that requires a result won't be returned directly. Instead all such operations will return a Promise.
Dexie also supports queueing operations, meaning you can start using the database directly after db.open() has been called even if open() has not finished yet. In case open() fails, queued operations will immediately fail with the error event from the open request. This means that you don't need to do catch() on db.open() since the error will be catched by any request towards the database.
Table and its subclass WriteableTable represents an object store. Table instances can be transactional or non-transactional. On your Dexie instance you will have direct access to non-transactional instances of WriteableTable for each object store you have defined in your schema.
var db = new Dexie("FriendsAndPetsDB");
db.version(1).stores({
friends: "++id,name,isCloseFriend",
pets: "++id,name,kind"
});
db.open();
db.friends.add({name: "Ingemar Bergman", isCloseFriend: false});
db.pets.add({name: "Josephina", kind: "dog", fur: "too long right now"});
Note: id++
means that the primary key is auto-incremented
Note2: You only need to specify properties that you wish to index. The object store will allow any properties on your stored objects but you can only query them by indexed properties
As you can see, db.friends
and db.pets
are instances of WriteableTable that you can operate on directly.
Table and WriteableTable are the entrypoints for doing all operations to your object stores, such as querying, adding, putting, deleting, clearing and modifying your data.
Since modern javascript IDEs now have very good code completion facilities, you get a benefit of coding by starting to type:
And when typing ahead to friends, the IDE will guide you further:
This also goes for Promise callbacks where your IDE will know the type of your input arguments to your callbacks.
Why is this so important? It's because the chance of misspelling your objects become significantly lower! You will code faster and better.
With Dexie it's possible control and monitor each database change. No matter which method being used for data manipulation, Dexie may tell whether a CREATE, UPDATE or DELETE is about to happen and offer the hook callbacks to manipulate the change if requested. It is also possible to hook into READ operations; to provide a proxy function that will be called whenever an object has been read from database and is about to be delivered to caller.
CRUD hooks that enables application code or addons to get involved in any of the CRUD operations taking place underhood. Whenever database is about to be read from or modified, they allow hook implementation to modify what will happen, or just react on the event.
The CRUD hooks could be quite powerful. It is possible to write Dexie addons that performs synchronization, observation, custom advanced indexes, foreignKey implementations, views etc.
Whenever you are going to do more than a single operation on your database in a sequence, you would normally use a transaction. Transaction represents a full ACID transaction. When working with transactions you get the following benefits:
- If modifying database and any error occur, every modification will be rolled back.
- You may do all write operations synchronously without the need to wait for it to finish before starting the next one.
- You may catch any error event or exception of any kind in one single catch() method of the transaction, making sure no exception what so ever will make your app just stall. Even "runtime" exceptions like the use of a misspelled variable will be caught even if it happens in the callback of a callback of your transaction callback...
- Remember that a browser can close down at any moment. Think about what would happen if the user closes the browser somewhere between your operations. Would that lead to an invalid state? If so, use a transaction - that will make its operations abort if browser is closed between operations.
Here is how you enter a transaction block:
db.transaction("rw", db.friends, db.pets, function() {
// Any database error event that occur will abort transaction and be sent to
// the catch() method below.
// The exact same rule if any exception is thrown whatsoever.
}).catch(function (error) {
// Log or display the error
});
Notes:
- 'friends' and 'pets' are objectStores registered using version() method.
- Replace
"rw"
with"r"
if you are just going to read from the stores. - Also errors occurring in nested callbacks in the block will be catched by the catch() method.
- It is also possible to prohibit the transaction from being aborted by catching specific errors. (See Dexie.transaction() ).
IndexedDB transactions are short-lived. Remember that! :) A transaction is auto-committed once you do not do anything with it within a javascript tick. So, if you do setTimeout(cb, 0) anywhere, don't expect your transaction to live! The only way of keeping a transaction alive between ticks is to perform a database operation on it. Then it will live until that operation fails (catch()) or succeeds (then()). You can then do another operation to keep it alive some more time, etc. However, keeping a transaction alive like that is not a good coding practice. There might be situations where you would interact with an ajax server and wish to keep the transaction alive between the ajax calls. In that situation, it is better to emulate atomicity differently than in a transaction because keeping a transaction alive will block the database.
There is no commit() method on transactions, because it is not needed since it will auto-commit if no errors occur. You can abort() it however.
Thanks to the backend architecture of indexedDB, database versioning is essential when working with indexedDB. With Dexie, you get an even easier upgrading framework built upon the indexedDB framework.
Lets say you initially have the following database schema:
var db = new Dexie("FriendsDB");
db.version(1).stores({friends: "id++,name"});
This schema only specifies a primary key "id" that is auto-incremented, and an index on the property "name". App may store other properties as well, such as phone
, email
etc but it will not be indexed:
db.friends.put({
name: "Arnold",
phone: "123456",
email: "[email protected]",
shoeSize: 88
});
Let's say that you publish your app and people starts using it. After a while, your user requests a new feature - to be able to search for friends with a certian shoeSize. But you have not indexed shoeSize in your schema, so how do you add that index to the next version of your app? Here's how:
- Keep the line
db.version(1).stores({friends: "id++,name"});
- Never touch it as long as there are users out there with that version running! - Instead, add a new line to your code:
db.version(2).stores({friends: "id++,name,shoeSize"});
.
Voila, that's it. Your code can now do stuff like db.friends.where('shoeSize').between(37,39), which would fail in version 1.
Ok, that's nice. But what if you'd need to change the data architecture? Let's say you want to split ´name´ into ´firstName´ and ´lastName´ and index those separate. Here's how you do it. (This time I present the entire code containing all versions, so you can see it in it's whole):
var db = new Dexie("FriendsDB");
db.version(1).stores({friends: "id++,name"});
db.version(2).stores({friends: "id++,name,shoeSize"};
db.version(3).stores({friends: "id++,shoeSize,firstName,lastName"}).upgrade(function(t) {
// An upgrade function for version 3 will upgrade data based on version 2.
t.friends.toCollection().modify(function(friend) {
// Modify each friend:
friend.firstName = friend.name.split(' ')[0];
friend.lastName = friend.name.split(' ')[1];
delete friend.name;
});
});
That's it. Your data will automatically upgrade for existing users.
Note: The upgrade()
method will always supply the upgrade transaction as the first argument to the callback, t
. The upgrade transaction is an instance of Transaction opened with all available object stores in your current and previous version.
So how is this upgrade framework of Dexie implemented?
- indexedDB's
onupgradeneeded
event is fired - Dexie inspects what version is currently used. If no database present, Dexie initializes the last version directly by parsing the stores schema syntax and adding stores and indexes accordingly.
- If a previous version is installed, Dexie will filter out the diff between each version and add/remove stores and indexes sequencially. So also with any registered upgrader function.
So how can Dexie know when an upgrader function has finished executing? It doesnt need to call back when finished. Well, Dexie will work similary to indexedDB spec, and automatically find out when no more database requests are pending before launching the next upgrader function.
Error handling: If any error occur in any upgrade function in the sequence, the upgrade transaction will roll back and db.open() will fail. This means that data will under no cicumbstances be left half-upgraded.
The populate Event
In case your database need initial data in order to work - data that must only be populated on database creation and never more, you can subscribe to the populate event. This will only be called in case the database is initially created - not when it is upgraded.
var db = new Dexie("MyTicketDB");
db.version(1).stores({
tickets: "++id,headline,description,statusId",
statuses: "++id,name,openess"
});
db.on("populate", function() {
// Init your DB with some default statuses:
db.statuses.add({id: 1, name: "opened", openess: true});
db.statuses.add({id: 2, name: "closed", openess: false});
db.statuses.add({id: 3, name: "resolved", openess: false});
db.statuses.add({id: 4, name: "wontfix", openess: false});
}
Here's also an example of how to populate data from an ajax call: Ajax Populate Sample
Dexie comes with its own implementation of Promise based on promise-light by Taylor Hakes that is Promise/A+ and ECMAScript 6 compliant. A Promise has a then() method which is called when operation completes or fails. The first argument to the then() method is the complete
callback and the second is the fail
callback. In ECMAScript 6, a catch() method is added as a shortcut for then(null, fn) - catching failures - this makes it possible to utilize then() as a success-method only and catch() as an error method only, making your code more readable. Dexie's implementation of Promise also has a finally() method that is called whether or not the opration fails or completes. All asynchronic methods in Dexie returns a Promise instance and this makes the API way more easy to use and makes error handling more robust.
How to use Promise line by line:
var arrayPromise = db.friends.where('name').startsWithIgnoreCase('arnold').toArray();
arrayPromise.then(function(a) { console.log(a.length); });
arrayPromise.catch(function(err) { console.error(err); );
But Dexie gives you a little shortcut in all methods returning a promise with a value, so the above code will be equal to:
db.friends.where('name').startsWithIgnoreCase('arnold').toArray(function(a) {
console.log(a.length);
}).catch(function(err) {
console.error(err);
});
Note: Promises are returned from all methods in Dexie that perform asynchronous work. Also, most methods that has a value result, such as toArray()
, also provide a shortcut for then()
- you can pass in your callback directly to the method instead of calling then():
db.friends.toArray().then (function (result) {
});
is equivalent to:
db.friends.toArray(function (result) {
});
With Dexie's implementation of Promise.catch() enables you to catch certain exception classes as you would do on java or C#:
db.friends.where('name').startsWithIgnoreCase('arnold').toArray(function(a) {
console.log(a.length);
}).catch(DOMError, function(e) {
console.error("DOMError occurred: " + err);
}).catch(TypeError, function(e) {
console.error("TypeError occurred: " + err);
}).catch(function(err) {
console.error("Unknown error occurred: " + err);
}).finally(function(){
console.log("Finally the query succeeded or failed.");
});
- http://www.html5rocks.com/en/tutorials/es6/promises/
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
- http://promises-aplus.github.io/promises-spec/
You can retrieve objects from you Table instances using two methods:
- Table.get() - retrieve an object by its primary key.
- Table.where() - do an advanced query.
Example of a get() query:
db.friends.get(2).then(function(friend) {
console.log("Friend number 2: " + JSON.stringify(friend));
}
Example of a simple where() query:
db.friends.where('shoeSize').above(37).count(function(count) {
console.log("I have " + count + " friends with shoesize above 37!");
});
Example of an advanced where() query:
db.friends.where('shoeSize')
.between(37, 40)
.or('name')
.anyOf(['Arnold','Ingemar'])
.and(function(friend) { return friend.isCloseFriend; })
.limit(10)
.each(function(friend){
console.log(JSON.stringify(friend));
});
Native indexedDB has no support for logical AND or OR operations. Dexie implements this in two different manner, which makes sense for their different purpuses in regard to performance. Dexie implements logical OR by executing two different requests simultaniously and act on the union of these request (more about this in this article ).
The or()
method takes a string argument and then works the same as where()
, whereas the and()
method takes a function argument (a filter) and filters the results in an iteration sequence.
So why is and() and or() implemented differently? The reason is that:
- Logical OR cannot be done by filtering - we must query the database with two queries to get it.
- We would gain no performance by letting the database handle Logical AND (launching two separate queries and the filter away entries that don't exist in both collections). The best pick for AND is undoubtedly a plain javascript filter. It also makes it obvious for the caller that it is important to pick a good index in the
where()
method and filter out the rest in theand()
filter.
Dexie.js - minimalistic and bullet proof indexedDB library