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
orRecord#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
orRecord#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
orRecord#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
andupdateMany
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 underdata
.
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.
Updated over 5 years ago
Continue with part 6 of the Tutorial or explore other documentation.