3. Connecting to a data source

Using adapters to connect JSData to your data.

Getting started

This page assumes you've read part 2 of the tutorial, Modeling your data.

JSData itself has no knowledge of any data sources—it doesn't know how to write to MySQL or read from Firebase. Instead, JSData defines an interface for CRUD operations and adapters provide the implementation. This use of the Adapter Pattern helps keep JSData moving forward and prevents it from turning into a monolithic mess.

There are a number of adapters available, and you can implement your own if necessary. In the browser, it's likely that your data source is a REST API, though you might also connect to Firebase, localStorage, etc. On the server, it's likely that your data source is a database, but any adapter that works in Node.js is also an option. Wherever you're using JSData, adapters connect JSData to your data.

At the end of this tutorial you should understand:

  • How to install adapters
  • How to setup and register adapters with JSData
  • How to customize adapters

Installing an adapter

Setting up an adapter is simple:

  1. Install an adapter
  2. Instantiate the adapter

Install an adapter

👍

Tip

All of the official JSData adapters are available via NPM, and the adapters that work in the browser are available via Bower, cdnjs.com, and jsDelivr.

Some of the adapters have peer dependencies that you must also install.

Here are some examples:

npm i --save js-data js-data-rethinkdb rethinkdbdash
npm i --save js-data js-data-mongodb mongodb bson
# js-data-http is for use in the browser
bower i --save js-data js-data-http
<!-- js-data-http is for use in the browser -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-data/3.0.0-rc.4/js-data.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-data-http/3.0.0-rc.2/js-data-http.min.js"></script>
# js-data-http is for use in the browser
# For use in Node.js, use js-data-http-node
npm i --save js-data js-data-http
# js-data-http-node is for use in Node.js
# For use in the browser, use js-data-http
npm i --save js-data js-data-http-node axios
npm i --save js-data js-data-documentdb documentdb
npm i --save js-data js-data-sql mysql knex

Instantiate the adapter

Here are some examples:

// Import as ES2015 module
import { MongoDBAdapter } from 'js-data-mongodb';

// Create an instance of the adapter
const adapter = new MongoDBAdapter();
// Import as CommonJS module
var MongoDBAdapter = require('js-data-mongodb').MongoDBAdapter;

// Create an instance of the adapter
var adapter = new MongoDBAdapter();
// Import as AMD module
define(['js-data-http'], function (JSDataHttp) {
  // Create an instance of the adapter
  var adapter = new JSDataHttp.HttpAdapter();
  // ...
  return adapter;
})
// Create an instance of the adapter
var adapter = new JSDataHttp.HttpAdapter();

Configuring the adapter

Some adapters have sufficient default settings that they can work without any configuration. Others might require certain options to be set upon instantiation.

👍

Tip

All official JSData adapters take an options object upon instantiation which you can use to configure the adapter.

Here are some examples:

import { MongoDBAdapter } from 'js-data-mongodb';

// Create an instance of the adapter
const adapter = new MongoDBAdapter({
  // Log debug information during operation
  debug: true,
  
  // Configure the MongoDB URI.
  // Default is mongodb://localhost:27017
  uri: 'mongodb://myUser:[email protected]:27017/myDB
});
import { SqlAdapter } from 'js-data-sql'

const adapter = new SqlAdapter({
  knexOpts: {
    // Required. Configure this adapter to connect to MySQL
    client: 'mysql',
  
    // Setup connection details
    connection: {
      host: '123.45.67.890',
      port: 1337,
      database: 'prod'
    },
  
    // Configure connection pool
    pool: {
      min: 0,
      max: 10
    }
  }
});
import { HttpAdapter } from 'js-data-http';

const adapter = new HttpAdapter({
  // Instead of using relative urls, force absolute
  // urls using this basePath
  basePath: 'https://api.myapp.com'
});
import { DocumentDBAdapter } from 'js-data-documentdb';

const docAdapter = new DocumentDBAdapter({
  documentOpts: {
    db: 'mydb',
    urlConnection: process.env.DOCUMENT_DB_ENDPOINT,
    auth: {
      masterKey: process.env.DOCUMENT_DB_KEY
    }
  }
});

👍

Tip

View a live demo that shows use of the HTTP adapter.

Registering the adapter

Yes, you can use an adapter instance directly (and you very well may do so in certain cases) but an adapter becomes really useful when it is plugged into the higher abstractions provided by JSData.

👍

Tip

Regardless of whether you're using the stateless Container, the cache-based DataStore or just individual Mapper instances, registering an adapter is the same:

registerAdapter(name, adapter[, options])

When you register an adapter you must provide a name by which the adapter can be referenced. Adapters registered with instances of Container, SimpleStore, or DataStore are shared by all Mappers defined on those instances.

// DataStore is mostly recommended for use in the browser
import { DataStore } from 'js-data';
import { HttpAdapter } from 'js-data-http';

// Create a store
const store = new DataStore();

// Create an adapter
const adapter = new HttpAdapter();

// Register the adapter with the store and make
// it the default adapter. Adapters registered
// with the store are shared with all of the
// store's Mappers.
store.registerAdapter('http', adapter, { 'default': true });

// The http adapter is now usable by any Mappers in "store"
// Container is mostly recommended for use
// in Node.js
import { Container } from 'js-data';
import { MongoDBAdapter } from 'js-data-mongodb';

// Create a store
const store = new Container();

// Create an adapter
const adapter = new MongoDBAdapter();

// Register the adapter with the store and make
// it the default adapter. Adapters registered
// with the store are shared with all of the
// store's Mappers.
store.registerAdapter('mongodb', adapter, { 'default': true });

// The mongodb adapter is now usable by any Mappers in "store"
// It is recommended that you use a Container
// or DataStore manage your Mappers
import { Mapper } from 'js-data';
import { HttpAdapter } from 'js-data-http';

// Create a mapper
const userService = new Mapper({
  name: 'user'
});

// Create an adapter
const adapter = new HttpAdapter();

// Register the adapter with the Mapper and make
// it the default adapter. It is recommended that
// you use DataStore or Container to organize
// your Mappers.
UserService.registerAdapter('http', adapter, { 'default': true });

// The http adapter is now usable by userService

Once the adapter is registered, it can be used in the CRUD methods, for example:

// DataStore is mostly recommended for use in the browser
import { DataStore } from 'js-data';
import { HttpAdapter } from 'js-data-http';

const store = new DataStore();
const httpAdapter = new HttpAdapter();
store.registerAdapter('http', httpAdapter, { 'default': true });

store.defineMapper('user', {
	endpoint: 'users'
});

// GET /users/1
store.find('user', 1).then((user) => {
  // ...
});
// Container is mostly recommended for use in Node.js
import { Container } from 'js-data';
import { SqlAdapter } from 'js-data-sql';

const store = new Container();
const sqlAdapter = new SqlAdapter({
  knexOpts: {
    client: 'mysql'
  }
});
store.registerAdapter('sql', sqlAdapter, { 'default': true });
store.defineMapper('user', {
  table: 'users'
});

// SELECT * FROM users WHERE id = 1;
store.find('user', 1).then((user) => {
  // ...
});
// It is recommended that you use a Container
// or DataStore manage your Mappers
import { Mapper } from 'js-data';
import { HttpAdapter } from 'js-data-http';

const userService = new Mapper({
  name: 'user',
  endpoint: 'users'
});
const httpAdapter = new HttpAdapter();
userService.registerAdapter('http', httpAdapter, { 'default': true });

// GET /users/1
userService.find(1).then((user) => {
  // ...
});

Customizing adapter behavior

All of the official JSData adapters provide hooks that can be overridden so you can customize adapter behavior. The easiest way to customize behavior is to override adapter methods during instantiation. The methods you provide to the instance will take precedence over the methods on the constructor's prototype. Any method can be overridden. While some methods are intended to be overridden, overriding others may introduce more complexity into your code than you wish, so take care.

Here are some examples:

import { MongoDBAdapter } from 'js-data-mongodb';

const adapter = new MongoDBAdapter({
  beforeCreate: function (mapper, props, opts) {
    // preserve default behavior
    MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts);
    
  	// Any record created via this adapter will
    // have a created_at field
    props.created_at = Date.now();
    
    // don't return anything, and this method will
    // be treated like it's synchronous
  }
});
import { MongoDBAdapter } from 'js-data-mongodb';

const adapter = new MongoDBAdapter({
  beforeCreate: function (mapper, props, opts) {
    // preserve default behavior
    MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts);
    
    // If you return a promise then MongoDBAdapter#create will
    // wait for the promise to resolve before continuing
    return new Promise((resolve, reject) => {
    	// do something asynchronous
      resolve();
    });
  }
});
import { MongoDBAdapter } from 'js-data-mongodb';
import _ from 'lodash';

const adapter = new MongoDBAdapter({
  beforeCreate: function (mapper, props, opts) {
    // preserve default behavior
    MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts);
    
  	// If you return a value or a promise that resolves
    // to a value, then MongoDBAdapter#create will use
    // that value instead of the "props" argument that was
    // passed into beforeCreate.
    
    // In this example, a new object is return that has
    // all of its keys transformed to snake_case. This
    // object will be saved instead of the "props" argument
    const copy = {};
    for (let key in props) {
      if (props.hasOwnProperty(key)) {
        copy[_.snakeCase(key)] = props[key];
      }
    }
    return copy;
  }
});
import { MongoDBAdapter } from 'js-data-mongodb';
import _ from 'lodash';

const adapter = new MongoDBAdapter({
  beforeCreate: function (mapper, props, opts) {
    // preserve default behavior
    MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts);
    
    // In this example, a new object is return that has
    // all of its keys transformed to snake_case. This
    // object will be saved instead of the "props" argument
		const copy = {};
    for (let key in props) {
      if (props.hasOwnProperty(key)) {
        copy[_.snakeCase(key)] = props[key];
      }
    }
    return copy;
  }
});
import { MongoDBAdapter } from 'js-data-mongodb';

const adapter = new MongoDBAdapter({
  beforeCreate: function (mapper, props, opts) {
    // preserve default behavior
    MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts);
    
    // If you throw an error or return a promise
    // that rejects, then the overall operation will
    // be aborted and the error will bubble up the
    // parent promise chain, e.g.:
    if (!props.email && Mapper.name === 'user') {
      throw new Error('email is required!');
    } else {
      return this.findAll(Mapper, {
        email: props.email
      }, {
        raw: false
      }).then((existingUsers) => {
        if (existingUsers.length) {
          throw new Error('email is taken!');
        }
      });
    }
  }
});

Extending an adapter

There may be a case where you want some fancier customization of an adapter. Since all of the components in JSData—including adapters—are just functions with methods on their prototype, you can extend them using JavaScript inheritance or mix-in patterns. Here we discuss using inheritance.

Let's start with a simple example that adds the same behavior as the Customizing adapter behavior example, but extends the class instead of configuring an instance:

import { MongoDBAdapter } from 'js-data-mongodb';

/**
 * You can do this in ES5 like so:
 *
 * var MyMongoDBAdapter = MongoDBAdapter.extend({
 *   beforeCreate: function (mapper, props, options) {
 *     props.created_at = Date.now();
 *   }
 * });
 *
 * var adapter = new MyMongoDBAdapter();
 */
class MyMongoDBAdapter extends MongoDBAdapter {
	beforeCreate (mapper, props, options) {
    // preserve default behavior
    super.beforeCreate(mapper, props, options)
    
    props.created_at = Date.now();
  }
}

const adapter = new MyMongoDBAdapter();
import { MongoDBAdapter } from 'js-data-mongodb';

/**
 * You can do this in ES5 like so:
 *
 * var MyMongoDBAdapter = MongoDBAdapter.extend({
 *   beforeCreate: function (mapper, props, options) {
 *     return new Promise(function (resolve, reject) {
 *       // do something asynchronous
 *       resolve();
 *     });
 *   }
 * });
 *
 * var adapter = new MyMongoDBAdapter();
 */
class MyMongoDBAdapter extends MongoDBAdapter {
	beforeCreate (mapper, props, opts) {
    // preserve default behavior
    super.beforeCreate(mapper, props, opts);
    
    return new Promise((resolve, reject) => {
      // do something asynchronous
    });
  }
}

const adapter = new MyMongoDBAdapter();
import { MongoDBAdapter } from 'js-data-mongodb';
import _ from 'lodash';
  
/**
 * You can do this in ES5 like so:
 *
 * var MyMongoDBAdapter = MongoDBAdapter.extend({
 *   beforeCreate: function (mapper, props, options) {
 *     var copy = {};
 *     for (var key in props) {
 *       if (props.hasOwnProperty(key)) {
 *         copy[_.snakeCase(key)] = props[key];
 *       }
 *     }
 *     return copy;
 *   }
 * });
 *
 * var adapter = new MyMongoDBAdapter()
 */
class MyMongoDBAdapter extends MongoDBAdapter {
  beforeCreate (mapper, props, opts) {
    // preserve default behavior
    super.beforeCreate(mapper, props, opts)
    
    const copy = {};
    for (let key in props) {
      if (props.hasOwnProperty(key)) {
        copy[_.snakeCase(key)] = props[key];
      }
    }
    return copy;
  }
}

const adapter = new MyMongoDBAdapter();
import { MongoDBAdapter } from 'js-data-mongodb';

/**
 * You can do this in ES5 like so:
 *
 * var MyMongoDBAdapter = MongoDBAdapter.extend({
 *   beforeCreate: function (mapper, props, opts) {
 *     MongoDBAdapter.prototype.beforeCreate.call(this, mapper, props, opts)
 *
 *     if (!props.email && Mapper.name === 'user') {
 *       throw new Error('email is required!');
 *     } else {
 *       return this.findAll(Mapper, {
 *         email: props.email
 *       }, {
 *         raw: false
 *       }).then((existingUsers) => {
 *         if (existingUsers.length) {
 *           throw new Error('email is taken!');
 *         }
 *       });
 *     }
 *   }
 * });
 *
 * var adapter = new MyMongoDBAdapter();
 */
class MyMongoDBAdapter extends MongoDBAdapter {
	beforeCreate (mapper, props, opts) {
    // Call "super" method
    super.beforeCreate(mapper, props, opts)
    
    if (!props.email && Mapper.name === 'user') {
      throw new Error('email is required!');
    } else {
      return this.findAll(Mapper, {
        email: props.email
      }, {
        raw: false
      }).then((existingUsers) => {
        if (existingUsers.length) {
          throw new Error('email is taken!');
        }
      });
    }
  }
}

const adapter = new MyMongoDBAdapter();

Here's a ​more complete example that tries Redis first on reads and updates Redis on writes:

import { MongoDBAdapter } from 'js-data-mongodb';
import { RedisAdapter } from 'js-data-redis';

const redisAdapter = new RedisAdapter();

// This is just one of several ways to do this
class CachedMongoDBAdapter extends MongoDBAdapter {
  async afterCreate (mapper, props, opts, record) {
    super.afterCreate(mapper, props, opts, record);
    // Add the newly created record to the Redis cache
    await redisAdapter.create(mapper, record);
  }
  
  async afterCreateMany (mapper, props, opts, records) {
    super.afterCreateMany(mapper, props, opts, records);
    // Add the newly created records to the Redis cache
    await redisAdapter.createMany(mapper, records);
  }
  
  async afterUpdate (mapper, id, props, opts, record) {
    super.afterUpdate(mapper, id, props, opts, record);
    // Add the newly update record to the Redis cache
    await redisAdapter.create(mapper, record);
  }
  
  async afterUpdateAll (mapper, props, query, opts, records) {
    // Add the newly update record to the Redis cache
    await redisAdapter.createMany(mapper, records);
  }
  
  async afterUpdateMany (mapper, props, opts, records) {
    super.afterUpdateMany(mapper, props, opts, records);
    // Add the newly update record to the Redis cache
    await redisAdapter.createMany(mapper, records);
  }
  
  async afterDestroy (mapper, id, opts) {
    super.afterDestroy(mapper, id, opts);
    await redisAdapter.destroy(mapper, id);
  }
  
  async afterDestroyAll (mapper, query, opts) {
    super.afterDestroyAll(mapper, query, opts);
    await redisAdapter.destroyAll(mapper, query);
  }
  
  async find (mapper, id, opts = {}) {
    // Check redis for the record
    let result = await redisAdapter.find(mapper, id, opts);
    
    // Determine whether the record was found
    let record = this.getOpt('raw', opts) ? result.data : result;
    // Determine whether the record was found
    if (record) {
      // Return cached record
      return result;
    }
    // Otherwise, find the record in MongoDB
    result = super.find(mapper, id, opts);
    record = this.getOpt('raw', opts) ? result.data : result;
    if (record) {
      // Add the record to the Redis cache
      await redisAdapter.create(mapper, record);
    }
    // Finally, return the newly cached record
    return record;
  }
}

const mongodbAdapter = new CachedMongoDBAdapter();

🚧

See an issue with this tutorial?

You can open an issue or better yet, suggest edits right on this page.


What’s Next

Continue with part 4 of the Tutorial or explore other documentation.