5. Saving data

How to use JSData to create, update, and delete data.

Getting started

This pages assumed you've read part 4 of the tutorial, Reading data.

Just as an ORM/ODM reads database records into in-memory objects, objects in memory can be saved back to a database. This tutorial covers the C (create), U (update), and D (delete) in CRUD with JSData.

At the end of this tutorial you should understand:

  • How to use JSData to create and save new records
  • How to update existing records
  • How to delete existing records
  • How to customize the write process

Continuing with our blog application example from previous tutorials: in order to publish a new blog post, a new row would need to be inserted into the posts table. In other words, a Post record needs to be created and saved to the database.

๐Ÿ‘

Tip

JSData is capable of creating and saving a single new record (Mapper#create or Record#save) or many new records in a batch (Mapper#createMany). See Creating and saving new records.

Fixing a typo in a blog post would mean editing the post's content and then applying that change to the appropriate row in the posts table.

๐Ÿ‘

Tip

JSData is capable of applying an update to a single record identified by its primary key (Mapper#update or Record#save), applying updates for multiple records (each identified by its primary key) in a batch (Mapper#updateMany), or applying a single update to multiple records identified by a selection query (Mapper#updateAll). See Updating existing records.

Deleting a blog post would mean removing a row from the posts table.

๐Ÿ‘

Tip

JSData is capable of deleting a single record identified by its primary key (Mapper#destroy or Record#destroy) or multiple records identified by a selection query (Mapper#destroyAll). See Deleting records.

Creating and saving new records

Let's write a new blog post. Our blog web application probably has some sort of View with a form for authoring new posts. Depending on the app framework or developer preference, there may or may not be a ViewModel dedicated to collecting and managing the state of the form inputs. For convenience, one might instantiate a Post record and bind the View directly to the Post record:

// Angular 1.x example
app.controller('NewPostCtrl', function () {
	// Create an empty, unsaved Post record
	this.post = store.createRecord('post')
})
<!-- Angular 1.x example -->
<form id="new-post" name="new-post" ng-submit="post.save()">
  <label for="title">Title</label>
  <input type="text" required id="title" name="title" ng-model="post.title" class="form-control"/>
  <label for="content">Content</label>
  <textarea required id="content" name="content" ng-model="post.content" class="form-control"></textarea>
  <button>Save</button>
</form>

This is a tiny example, but it shows the power and convenience of JSData. Takes this for example: ng-submit="post.save()". That's only 22 characters, but it does a whole lot. When the form is submitted, the unsaved โ€‹Post record will save itself, in this case with an HTTP POST.

Under the hood, the record passes its current state to Mapper#create, which sends the properties to the configured adapter's create method. In this example, an Http adapter is being used, so an HTTP request is formed and the payload is sent to the server. In one way or another the server tells the database to insert a new record into the posts table, and most likely a primary key is generated for the new row. The server finally responds to the HTTP request with the newly created post which now has a primary key. Calling post.save() or store.create('post', props) on the server would save the post to the database.

See some examples: Angular 1, Angular 2, React

Here is the same example, except this time the View doesn't work with a Post record instance at all:

// Angular 1.x example
app.controller('NewPostCtrl', function (store) {
	// Create an empty, unsaved Post record
	this.formData = {
    title: '',
    content: ''
  };
  
  this.onSubmit = () => {
    store.create('post', {
      title: this.formData.title,
      content: this.formData.content
    }).then((post) => {
      // Navigate to the new post
      window.location = `/posts/${post.id}`;
    }, () => {
      alert('failed to save!');
    });
  }
});
<!-- Angular 1.x example -->
<form id="new-post" name="new-post" ng-submit="onSubmit()">
  <label for="title">Title</label>
  <input type="text" required id="title" name="title" ng-model="formData.title" class="form-control"/>
  <label for="content">Content</label>
  <textarea required id="content" name="content" ng-model="formData.content" class="form-control"></textarea>
  <button>Save</button>
</form>

If the server also uses JSData, then it might save the new Post record like this:

app.post('/post', function (req, res, next) {
  store.create('post', req.body).then((post) => {
    res.send(post)
  }, next)
})

The server would be using a different adapter than the clientโ€”an adapter that knows how to write to a database.

Here are some examples that show how to create and save new records:

const userProps = {
  name: 'John Anderson'
};
console.log(userProps); // { name: "John Anderson" }

// Create and save a new user record
store.create('user', userProps).then((user) => {
  // New row has been inserted into the "users" table
  // and a new id auto generated
  console.log(user); // { id: 1234, name: "John Anderson" }
});
// Create Record instance
const user = store.createRecord('user', {
  name: 'John Anderson'
});

console.log(user); // { name: "John Anderson" }

user.role = 'admin';
console.log(user); // { name: "John Anderson", role: "admin" }

// Save new record
user.save().then((user) => {
  // New row has been inserted into the "users" table
  // and a new id auto generated
  console.log(user); // { id: 1234, name: "John Anderson", role: "admin" }
  
  const user2 = store.createRecord('user', {
    name: 'Sally Johnson'
  });
  
  console.log(user2); // { name: "Sally Johnson" }
  
  // Same as "return user2.save()"
  return store.create('user', user2);
}).then((user2) => {
  console.log(user2); // { id: 1235, name: "Sally Johnson" }
});
// Create and save multiple new records in a batch
store.createMany('comment', [
  {
    content: 'Good job!',
    post_id: 2
  },
  {
    content: 'Nice post!',
    post_id: 2
  }
]).then((comments) => {
  // New rows have been inserted into the "comments" table
  // and new ids auto generated
  console.log(comments[0]); // { id: 1234, content: "Good job!", post_id: 2 }
  console.log(comments[1]); // { id: 1235, content: "Nice post!", post_id: 2 }
})
const userProps = {
  name: 'John Anderson'
};
console.log(userProps); // { name: "John Anderson" }

// Create and save a new user record
userService.create(userProps).then((user) => {
  // New row has been inserted into the "users" table
  // and a new id auto generated
  console.log(user); // { id: 1234, name: "John Anderson" }
});

๐Ÿ‘

Tip

View the live demo of the "Creating and saving new records" section.

Updating existing records

Let's say we found a typo in a blog post, and we need to fix it. To do this, we need to edit the post'sโ€‹ content and then apply the change to post's record in the database. With JSData there are multiple ways to update existing records:

  • Use update and updateMany to update records based on their primary keys
  • Use updateAll to apply a single update to zero or more matching records
  • Call Record#save on an active record that has a primary key

Here are some examples:

// Update existing post record that has id "1"
store.update('post', 1, {
  content: 'Updated content'
}).then((post) => {
  console.log(post); // { id: 1, name: "Updated content" }
});
// Update multiple existing post records
store.updateMany('post', [
  // Mark post "1" as published
  {
    id: 1,
    status: 'published'
  },
  // Mark post "2" as a draft
  {
    id: 2,
    status: 'draft'
  }
]).then((posts) => {
  console.log(posts[0]); // { id: 1, status: "published", ... }
  console.log(posts[1]); // { id: 2, status: "draft", ... }
});
// Apply a single update to records chosen
// by a selection query

const updateToApply = {
  status: 'draft'
};

const selectionQuery = {
  user_id: 1
};

// Update all records whose user_id is 1 to be in draft status
store.updateAll('post', updateToApply, selectionQuery).then((posts) => {
  console.log(posts[0]); // { id: 134, user_id: 1, status: "draft", ... }
  console.log(posts[1]); // { id: 412, user_id: 1, status: "draft", ... }
  // ...
});
console.log(post); // { id: 1, status: "draft", ... }
post.status = 'published';

post.save().then((post) => {
  console.log(post); // { id: 1, status: "published", ... }
});

๐Ÿ‘

Tip

View the live demo of the "Updating existing records" section.

Deleting records

Turns out, the โ€‹last blog post we wrote was garbage, so we need to delete it. To do this, we need to remove the post record from the database. With JSData there are multiple was to delete existing records:

  • Use destroy to delete a record by its primary key
  • Use destroyAll to delete zero or more records chosen by a selection query
  • Call Record#destroy on an active record that has a primary key

Here are some examples:

// DataStore#destroy is a wrapper around the simpler Mapper#destroy

// Delete existing post record that has id "1"
store.destroy('post', 1).then(() => {
  // The post has been destroyed and
  // removed from the in-memory store
  console.log(store.get('post', 1)); // undefined
});
// DataStore#destroyAll is a wrapper around the simpler Mapper#destroyAll

// Delete all records chosen by a selection query
const selectionQuery = {
  status: 'draft'
};

// Delete all records that are "draft" status
store.destroyAll('post', selectionQuery).then(() => {
  // The posts have been destroyed and
  // removed from the store
  console.log(store.filter('post', selectionQuery)); // []
});
// Record#destroy will call DataStore#destroy or Mapper#destroy,
// depending on if DataStore is being used or not

console.log(post); // { id: 1, status: "draft", ... }

post.destroy().then(() => {
  // The post has been destroyed via the connected adapter
});

Sending options to the adapter

JSData's CRUD methods delegate to an adapter because they don't inherently know how exactly to talk to MySQL, Firebase, etc. So when you're calling Mapper#update, Container#create, Record#destroy, DataStore#find, etc., you're also calling an adapter's update, create, destroy, or find method.

You may find yourself wanting to pass options to the adapter to further customize behavior. It's simple to pass options to the adapter, just include the adapter options in the options argument.

๐Ÿ‘

Tip

The options argument passed to JSData's CRUD methods will be forwarded to the adapter.

Let's say you're using the Http adapter and you want to update a record, but you want the method to be PATCH instead of PUT. method isn't an option of Mapper#update, but it is an option recognized by HttpAdapter#update, therefore, when using the HttpAdapter you can pass a method option to Mapper#update and it will be passed through to HttpAdapter#update. Here's an example:

const resourceName = 'post'
const postId = 1
const updateToApply = { status: 'draft' }
const options = { method: 'PATCH' }

// PATCH /posts/1 {"status":"draft"}
store.update(resourceName, postId, updateToApply, options).then((post) => {
  post.status // "draft"
})

Here are some more examples:

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const adapter = new HttpAdapter()
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user')

// POST /user?debug=true {"name":"John"}
store.create('user', { name: 'John'}, { params: { debug: true } }) // "params" is an axios option

// PATCH /user/1 {"name":"Johhny"}
store.update('user', 1, { name: 'Johnny' }, { method: 'PATCH' }) // "method" is an http adapter option

// PUT /user/1
store.update('user', 1, { name: 'Johnny' }, { debug: true }) // "debug" is an http adapter option

// DELETE https://otherapi.com/user/1
store.destroy('user', 1, { basePath: 'https://otherapi.com' }) // "basePath" is an http adapter option

// PUT /user-detail/1 {"name":"Johnny"}
store.update('user', 1, { name: 'Johnny'}, { url: '/user-detail/1' }) // "url" is an axios option
import {Container} from 'js-data'
import {RethinkDBAdapter} from 'js-data-rethinkdb'

const adapter = new RethinkDBAdapter()
const store = new Container()

store.registerAdapter('rethinkdb', adapter, { default: true })

store.defineMapper('user')

store.create('user', { name: 'John' }, {
  // "durability" is an r#insert option
  insertOpts: { durability: 'hard' },
  // "readMode" is an r#run option
  runOpts: { readMode: 'outdated' }
})

store.destroyAll('user', { role: 'dev' }, {
  // "profile" is an r#run option
  runOpts: { profile: true },
  raw: true
}) // "profile" is an r#run option

store.update('user', 1, { name: 'Johnny' }, { debug: true }) // "debug" is a rethinkdb adapter option

Hooking into the create, update, and delete processes

JSData has many lifecycle hooks that you can override to add your custom behavior to different parts of the CRUD process.

beforeCreate, afterCreate, beforeCreateMany, afterCreateMany, beforeUpdate, afterUpdate, beforeUpdateMany, afterUpdateMany, beforeUpdateAll, afterUpdateAll, beforeDestroy, afterDestroy, beforeDestroyAll, and afterDestroyAll are available and can be overridden to hook into the write process. Some adapters may have additional hooks that are called during a create, createMany, update, updateAll, updateMany, destroy or destroyAll operation, e.g. the deserialize hook of the HTTP adapter.

Here are some create lifecycle hook examples:

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const hooks = []
  
const adapter = new HttpAdapter({
	beforeCreate (Mapper, props, options) {
    hooks.push('adapter beforeCreate')
  },
  afterCreate (Mapper, props, options, result) {
    hooks.push('adapter afterCreate')
  }
})
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user', {
  beforeCreate (props, options) {
    hooks.push('user beforeCreate')
  },
  afterCreate (props, options, result) {
    hooks.push('user afterCreate')
  }
})

store.create('user', { name: 'John' }).then((user) => {
	// [
  //   'user beforeCreate',
  //   'adapter beforeCreate
  //   'adapter afterCreate',
  //   'user afterCreate'
  // ]
  console.log(hooks)
})
// this example shows how hooks can return
// a promise to make them async

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const hooks = []
  
const adapter = new HttpAdapter({
	beforeCreate (Mapper, props, options) {
    return new Promise(function (resolve) {
			hooks.push('adapter beforeCreate')
      resolve()
    })
  },
  afterCreate (Mapper, props, options, result) {
    return new Promise(function (resolve) {
    	hooks.push('adapter afterCreate')
      resolve()
    })
  }
})
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user', {
  beforeCreate (props, options) {
    return new Promise(function (resolve) {
    	hooks.push('user beforeCreate')
      resolve()
    })
  },
  afterCreate (props, options, result) {
    return new Promise(function (resolve) {
    	hooks.push('user afterCreate')
      resolve()
    })
  }
})

store.find('user', 1).then(function (user) {
  // [
  //   'user beforeCreate',
  //   'adapter beforeCreate',
  //   'adapter afterCreate',
  //   'user afterCreate'
  // ]
  console.log(hooks)
})
// this example shows modifying arguments
// as they pass by

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const hooks = []
  
const adapter = new HttpAdapter({
	beforeCreate (Mapper, props, options) {
    props.created_at = new Date()
    hooks.push('adapter beforeCreate')
  },
  afterCreate (Mapper, props, options, result) {
    hooks.push('adapter afterCreate')
    result._timestamp = new Date()
  }
})
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user', {
  beforeCreate (props, options) {
    props.role = props.role || 'viewer'
    hooks.push('user beforeCreate')
  },
  afterCreate (props, options, result) {
    hooks.push('user afterCreate')
    result._timestamp = result._timestamp.getTime()
  }
})

store.find('user', 1).then(function (user) {
  // [
  //   'user beforeCreate',
  //   'adapter beforeCreate',
  //   'adapter afterCreate',
  //   'user afterCreate'
  // ]
  console.log(hooks)
})
// this example shows hooks that can return a
// value which will replace the last argument

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const hooks = []
  
const adapter = new HttpAdapter({
	afterCreate (Mapper, props, options, result) {
    hooks.push('adapter afterCreate')
    return result[Mapper.name]
  }
})
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user', {
  afterCreate (props, options, result) {
    hooks.push('user afterCreate')
    
    // can replace result AND be async
    return new Promise(function (resolve) {
    	resolve({
        id: result.id,
        profile: result
      })
    })
  }
})

store.find('user', 1).then(function (user) {
  // [
  //   'adapter afterCreate',
  //   'user afterCreate'
  // ]
  console.log(hooks)
})

The other lifecycle hooks work similarly. You can read more about them at http://api.js-data.io.

Making creates, updates, and deletes with metadata

Sometimes you want to receive more than just the resulting record(s)โ€”you want the metadata too. This is especially common with the HTTP adapter. Use the raw option to receive a result object that contains the data and any metadata.

๐Ÿ‘

Tip

Use the raw option to receive a more detailed response, with the normally returned response nested in the detailed response under data.

Here are some examples:

import {DataStore} from 'js-data'
import {HttpAdapter} from 'js-data-http'

const adapter = new HttpAdapter()
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user')

store.create('user', { name: 'John' }).then((user) => {
	user // { id: 1, name: "John" }
  
  store.create('user', { name: 'Sally' }, { raw: true })
}).then((result) => {
  result.data // { id: 2, name: "Sally" }
  result.created // 1
  result.headers // { ... }
  result.statusCode // 200
  result.adapter // "http"
  // etc.
})
import {Container} from 'js-data'
import {RethinkDBAdapter} from 'js-data-rethinkdb'

const adapter = new RethinkDBAdapter()
const store = new Container()

store.registerAdapter('rethinkdb', adapter, { default: true })

store.defineMapper('user')

store.destroy('user', 1).then(() => {
  return store.destroy('user', 2, { raw: true })
}).then((result) => {
  result.deleted // 1
  result.adapter // "rethinkdb"
  // etc.
})

Making custom creates, updates, and deletes

Sometimes you want to run a query, insert, update, or delete data in such a way that can't be satisfied by JSData's standard CRUD method. You may have to talk directly to your storage engine.

๐Ÿ‘

Tip

Adapters typically expose the client driver that they use so you can execute custom queries.

Here are some examples:

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

const adapter = new HttpAdapter()
const store = new DataStore()

store.registerAdapter('http', adapter, { default: true })

store.defineMapper('user')

// Make a PUT request
// PUT /user/1/report ["key","value"]
adapter.PUT('/user/1/report', ['key', 'value'])

// More low-level
// PUT /user/1/report ["key","value"]
adapter.HTTP({
  url: '/user/1/report',
  method: 'PUT',
  data: ['key', 'value']
})

// Even more low-level, use axios directly
// PUT /user/1/report ["key","value"]
adapter.http({
  url: '/user/1/report',
  method: 'PUT',
  data: ['key', 'value']
})

addAction('userAction', {
  getEndpoint (UserService, options) {
    let url = `/user-actions/${options.action}`
    if (options.id !== undefined) {
      url = `${url}/${options.id}`
    }
    return url
  },
  method: 'POST',
  response (res) {
    return res.data
  }
})(store.getMapper('user'))

// POST /userActions/makeAdmin/1
store.getMapper('user').userAction({
  id: 1,
  action: 'makeAdmin'
}).then((result) => { /*...*/ })

// POST /userActions/deactivate?active=true
store.getMapper('user').userAction({
  action: 'deactivate',
  method: 'POST',
  params: {
    active: true
  }
}).then((activeUsers) => { /*...*/ })

// POST /userActions/login
//   body: {"username":"bob","password":"welcome1234"}
store.getMapper('user').userAction({
  action: 'login',
  data: {
    username: 'bob',
    password: 'welcome1234'
  }
}).then((result) => { /*...*/ })

๐Ÿšง

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 6 of the Tutorial or explore other documentation.