From 6f8d55610999b7c9cbe1497cdeac9e09ad040b32 Mon Sep 17 00:00:00 2001 From: pangratz Date: Tue, 12 Jan 2016 21:42:57 +0100 Subject: [PATCH 1/5] ember-data: Model Lifecycle Hooks --- text/0000-model-lifecycle-hooks.md | 475 +++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 text/0000-model-lifecycle-hooks.md diff --git a/text/0000-model-lifecycle-hooks.md b/text/0000-model-lifecycle-hooks.md new file mode 100644 index 0000000000..809a9d9c81 --- /dev/null +++ b/text/0000-model-lifecycle-hooks.md @@ -0,0 +1,475 @@ +- Start Date: 2016-03-04 +- RFC PR: (leave this empty) +- Ember Issue: (leave this empty) + +# Summary + + + +The basic idea of model lifecycle hooks is similar to component lifecycle +hooks. These hooks get invoked when the model's backing data changes, giving +the model a chance to compute derived properties and to copy properties from +the payload. + +# Motivation + + + +A first implementation of [RFC #57](https://github.com/emberjs/rfcs/pull/57) +has been added in +[emberjs/data#3303](https://github.com/emberjs/data/pull/3303) and is currently +available in `ember-data` behind the `ds-references` feature flag. This already +solves some use cases, but there is still something missing. + +Currently there is no public API to get notified when a relationship is +changed. Additional properties might be derived depending on the value of a +relationship. Think of a boolean indicating if comments of a post are loaded. +It is also difficult to know when the backing data of a model has been updated. + +This RFC proposes new public API's (hooks on `DS.Model` similar to the ones for +`Ember.Component`) so those use cases can be implemented. + + +# Detailed design + + + + +This RFC proposes the addition of new hooks available in `DS.Model`: + +- [Relationship hooks](#relationship-hooks), which are invoked when a `belongsTo` or `hasMany` is initialized or updated + - `didInitRelationship(name, reference)` + - `didUpdateRelationship(name, reference)` + - `didReceiveRelationship(name, reference)` + +- [Data hooks](#model-data-hooks) for when the backing data of a model is modified + - `didInitData(reference, payload)` + - `didUpdateData(reference, payload)` + - `didReceiveData(reference, payload)` + - `didUpdate(reference)` + +## Relationship hooks + +##### didInitRelationship + +```js +/** + * The relationship was initialized for the first time. + * + * @param name String name of the relationship + * @param reference {DS.BelongsToReference|DS.HasManyReference} reference to the relationship + */ +``` + +##### didUpdateRelationship + +```js +/** + * The relationship was updated. + * + * @param name String name of the relationship + * @param reference {DS.BelongsToReference|DS.HasManyReference} reference to the relationship + */ +``` + + +##### didReceiveRelationship + +```js +/** + * Runs in both situations (@didInitRelationship and @didUpdateRelationship). + * + * @param name String name of the relationship + * @param reference {DS.BelongsToReference|DS.HasManyReference} reference to the relationship + */ +``` + + +## Data hooks + +##### didInitData + +```js +/** + * Data from the backend (via the adapter) was provided for the first time. + * + * @param reference DS.RecordReference reference to this model + * @param payload Object payload from the adapter + */ +``` + +##### didUpdateData + +```js +/** + * Data from the backend (via the adapter) was updated. + * + * @param reference DS.RecordReference reference to this record + * @param payload Object payload from the adapter + */ +``` + +##### didReceiveData + +```js +/** + * Runs in both situations (@didInitData and @didUpdateData). + * + * @param reference DS.RecordReference reference to this record + * @param payload Object payload from the adapter + */ +``` + +##### didUpdate + +**TODO** find different name, since there is already a `didUpdate` hook on `DS.Model`, which is invoked after a `store#updateRecord` + +```js +/** + * Invoked when either the data from the backend has changed or the data + * has been changed locally via `store.push` or `store.pushPayload`. + * + * This hook can be used to update properties which are derived from + * other attributes or relationships. + * + * @param reference DS.RecordReference reference to this record + */ +``` + +## Showcase of which hooks are invoked when + +Consider the following `post` model definition: + +```js +// app/models/post.js +import Model from "ember-data/model"; +import { belongsTo, hasMany } from "ember-data/relationships"; + +export default Model.extend({ + author: belongsTo(), + category: belongsTo(), + comments: hasMany() +}); +``` + +Then the following relationship and data hooks are invoked: + +```js +var post = store.createRecord('post', { + author: 1 +}); + +// Post#didInitRelationship: author, +// Post#didInitRelationship: category, +// Post#didInitRelationship: comments, +// Post#didReceiveRelationship: author, +// Post#didReceiveRelationship: category, +// Post#didReceiveRelationship: comments, + +// ---------------------------------------------------------------------- + +post.set('author', store.peekRecord('author', 2)); + +// Post#didUpdateRelationship: author, +// Post#didReceiveRelationship: author, + +// ---------------------------------------------------------------------- + +/** + * POST /posts + * { + * data: { + * id: 1, + * type: 'post', + * relationships: { + * author: { + * data: { id: 1, type: 'author' } + * }, + * category: { + * data: { id: 1, type: 'category' } + * } + * } + * }, + * included: [ + * { + * id: 1, + * type: 'category', + * attributes: { + * label: 'uncategorized' + * } + * } + * ] + * } + */ +post.save() + +// Post#didInitData +// Post#didReceiveData +// Post#didUpdate +// Post#didUpdateRelationship: category, +// Post#didReceiveRelationship: category, + +// ---------------------------------------------------------------------- + +var anotherPost = store.push({ + data: { + id: 2, + type: 'post' + } +}); + +// Post#didUpdate +// Post#didInitRelationship: author, +// Post#didInitRelationship: category, +// Post#didInitRelationship: comments, +// Post#didReceiveRelationship: author, +// Post#didReceiveRelationship: category, +// Post#didReceiveRelationship: comments, + +// ---------------------------------------------------------------------- + +/** + * PUT /posts/1 + * { + * data: { + * id: 2, + * type: 'post', + * relationships: { + * category: { + * data: { id: 1, type: 'category' } + * } + * } + * }, + * included: [ + * { + * id: 1, + * type: 'category', + * attributes: { + * label: 'uncategorized' + * } + * } + * ] + * } + */ +anotherPost.save() + +// Post#didUpdateData +// Post#didReceiveData +// Post#didUpdate +// Post#didUpdateRelationship: category, +// Post#didReceiveRelationship: category, + +// ---------------------------------------------------------------------- + +anotherPost.get("category"); + +// Category#didInitData +// Category#didReceiveData +// Category#didUpdate +``` + + +## Use Cases + +### Check if there are comments for a post without triggering a request to server + +```js +// app/models/post.js +import Model from "ember-data/model"; +import { hasMany } from "ember-data/relationships"; + +export default Model.extend({ + comments: hasMany(), + + didReceiveRelationshp(name, relationship) { + if (name === "comments") { + this.set("commentsLoaded", relationship.value() !== null); + } + } +}); +``` + +### Get all ids without triggering a request to server + +```js +// app/models/post.js +import Model from "ember-data/model"; +import { hasMany } from "ember-data/relationships"; + +export default Model.extend({ + comments: hasMany()o, + + didReceiveData(/* reference, payload */) { + this.set('commentIds', this.hasMany('comments').ids()); + } +}); + +/** + * GET /posts/1 + * + * data: { + * id: 1, + * type: 'post', + * relationships: { + * comments: { + * data: [{ id: 1, type: 'comment' }] + * } + * } + * } + */ +store.findRecord('post', 1).then(function(post) { + assert.deepEqual(post.get("commentIds"), ["1"]); +}); +``` + +### Update properties based on the meta of a relationship + +```js +// app/models/post.js +import Model from "ember-data/model"; +import { hasMany } from "ember-data/relationships"; + +export default Model.extend({ + comments: hasMany(), + + didReceiveData(/* reference, payload */) { + this.set('totalComments', this.hasMany('comments').meta().total); + } +}); + +/** + * GET /posts/1 + * + * data: { + * id: 1, + * type: 'post', + * relationships: { + * comments: { + * data: [{ id: 1, type: 'comment' }], + * meta: { total: 123 } + * } + * } + * } + */ +store.findRecord('post', 1).then(function(post) { + assert.deepEqual(post.get("totalComments"), 123); +}); +``` + +# How We Teach This + + + +This enhances the known programming model of hooks by adding one for additional +use cases. + +Since the hooks are mostly useful in combination with `ds-references`, a +dedicated blog post should make developers familiar with references and hooks. +Also the guides need to contain a detailed section outlining the use cases. + +# Drawbacks + + + +- increased API surface, as this adds 7 more hooks to the `DS.Model` API (along +the currently available `didCreate`, `didUpdate` and `didLoad` hooks) + +# Alternatives + + + +- `¯\_(ツ)_/¯` + +# Unresolved questions + + + +- There is currently already a `didUpdate` hook, so the one in this RFC needs to be renamed; something like `didUpdateData`? + +- Should there also be `didInitAttribute`, `didUpdateAttribute` and `didReceiveAttribute` hooks? + +- Is the `RecordReference` argument for the data hooks `didInitData`, `didReceiveData`, `didUpdateData` and `didUpdate` necessary? + +- Which payload is passed to the data hooks? The normalized (JSON-API) or the original? Should the payload be passed at all? + +- With which payload is `didInitData` invoked for sideloaded records? I would say with the payload when the record is materialized for the first time (aka the first time the record is requested from the store). + +```js +var post = store.push({ + data: { + id: 1, + type: 'post', + relationships: { + category: { + data: { id: 1, type: 'category' } + } + } + }, + included: [ + { + id: 1, + type: 'category', + attributes: { + label: 'uncategorized' + } + } + ] +}); + +// push post again, but this time the sideloaded category has a different label attribute +store.push({ + data: { + id: 1, + type: 'post', + relationships: { + category: { + data: { id: 1, type: 'category' } + } + } + }, + included: [ + { + id: 1, + type: 'category', + attributes: { + label: 'UNCATEGORIZED' + } + } + ] +}); + +// retrieve DS.Model instance of category for the first time (it is only an InternalModel before the next line) +post.get("category"); + +// Category#didInitData , { label: "uncategorized" } +// OR +// Category#didInitData , { label: "UNCATEGORIZED" } +``` From 964ce5db00738c73e4d7a7eb5cdbafc619a2ffab Mon Sep 17 00:00:00 2001 From: pangratz Date: Mon, 7 Mar 2016 22:32:06 +0100 Subject: [PATCH 2/5] Fix typo --- text/0000-model-lifecycle-hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-model-lifecycle-hooks.md b/text/0000-model-lifecycle-hooks.md index 809a9d9c81..73993991ee 100644 --- a/text/0000-model-lifecycle-hooks.md +++ b/text/0000-model-lifecycle-hooks.md @@ -305,7 +305,7 @@ import Model from "ember-data/model"; import { hasMany } from "ember-data/relationships"; export default Model.extend({ - comments: hasMany()o, + comments: hasMany(), didReceiveData(/* reference, payload */) { this.set('commentIds', this.hasMany('comments').ids()); From d6a8faa38370c13dbdd92284ca4c66a6e4d3ca86 Mon Sep 17 00:00:00 2001 From: pangratz Date: Mon, 7 Mar 2016 22:33:14 +0100 Subject: [PATCH 3/5] Use correct anchor to data-hooks --- text/0000-model-lifecycle-hooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-model-lifecycle-hooks.md b/text/0000-model-lifecycle-hooks.md index 73993991ee..9aef29c273 100644 --- a/text/0000-model-lifecycle-hooks.md +++ b/text/0000-model-lifecycle-hooks.md @@ -50,7 +50,7 @@ This RFC proposes the addition of new hooks available in `DS.Model`: - `didUpdateRelationship(name, reference)` - `didReceiveRelationship(name, reference)` -- [Data hooks](#model-data-hooks) for when the backing data of a model is modified +- [Data hooks](#data-hooks) for when the backing data of a model is modified - `didInitData(reference, payload)` - `didUpdateData(reference, payload)` - `didReceiveData(reference, payload)` From 99416e4173df3c5d3688a3d1fccbed30ee3ab4a2 Mon Sep 17 00:00:00 2001 From: pangratz Date: Mon, 7 Mar 2016 23:01:41 +0100 Subject: [PATCH 4/5] Clarify invocation of hooks happens on materialized records --- text/0000-model-lifecycle-hooks.md | 79 +++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/text/0000-model-lifecycle-hooks.md b/text/0000-model-lifecycle-hooks.md index 9aef29c273..ef70bae9f3 100644 --- a/text/0000-model-lifecycle-hooks.md +++ b/text/0000-model-lifecycle-hooks.md @@ -146,21 +146,98 @@ This RFC proposes the addition of new hooks available in `DS.Model`: ## Showcase of which hooks are invoked when +The hooks are invoked after the data has been set on the record and all +relationships are settled; similar to the existing `didUpdate`, `didLoad`, etc. +hooks. + Consider the following `post` model definition: ```js // app/models/post.js import Model from "ember-data/model"; +import attr from "ember-data/attr"; import { belongsTo, hasMany } from "ember-data/relationships"; export default Model.extend({ + title: attr(), author: belongsTo(), category: belongsTo(), comments: hasMany() }); ``` -Then the following relationship and data hooks are invoked: +To prevent unecessary materialization of records, the hooks introduced in this +RFC are only invoked on records (instances of `DS.Model`), which exist at the +time. Consider the following scenario: + +```js +store.push({ + data: { + id: 'main', + type: 'post', + attributes: { + title: 'first push' + } + }, + included: [ + { + id: 'sideloaded', + type: 'post', + attributes: { + title: 'first push' + } + } + ] +}); + +store.push({ + data: { + id: 'main', + type: 'post', + attributes: { + title: 'second push' + } + }, + included: [ + { + id: 'sideloaded', + type: 'post', + attributes: { + title: 'second push' + } + } + ] +}); + +let mainPost = store.peekRecord('post', 'main'); +let sideloadedPost = store.peekRecord('post', 'sideloaded'); +``` + +The `didInitData` hook on `mainPost` is invoked with the state after the first +`store.push`, since the record is materialized and return from the first +`store.push`: + +```js +didInitData: function() { + assert.equal(this.get("title"), "first title"); +} +``` + +The `didInitData` hook on `sideloadedPost` on the other hand is invoked with +the state after the second `store.push`, since the record has been materialized +after that for the first time via `store.peekRecord`: + +```js +didInitData: function() { + assert.equal(this.get("title"), "updated title"); +} +``` + + +--- + + +The following relationship and data hooks are invoked: ```js var post = store.createRecord('post', { From b562a7e546fad10646b9d164ba28ff8e54dbc9c9 Mon Sep 17 00:00:00 2001 From: pangratz Date: Tue, 8 Mar 2016 08:09:12 +0100 Subject: [PATCH 5/5] Remove unresolved question, which has been addressed now --- text/0000-model-lifecycle-hooks.md | 54 ------------------------------ 1 file changed, 54 deletions(-) diff --git a/text/0000-model-lifecycle-hooks.md b/text/0000-model-lifecycle-hooks.md index ef70bae9f3..04a6d07d69 100644 --- a/text/0000-model-lifecycle-hooks.md +++ b/text/0000-model-lifecycle-hooks.md @@ -496,57 +496,3 @@ Optional, but suggested for first drafts. What parts of the design are still TBD - Is the `RecordReference` argument for the data hooks `didInitData`, `didReceiveData`, `didUpdateData` and `didUpdate` necessary? - Which payload is passed to the data hooks? The normalized (JSON-API) or the original? Should the payload be passed at all? - -- With which payload is `didInitData` invoked for sideloaded records? I would say with the payload when the record is materialized for the first time (aka the first time the record is requested from the store). - -```js -var post = store.push({ - data: { - id: 1, - type: 'post', - relationships: { - category: { - data: { id: 1, type: 'category' } - } - } - }, - included: [ - { - id: 1, - type: 'category', - attributes: { - label: 'uncategorized' - } - } - ] -}); - -// push post again, but this time the sideloaded category has a different label attribute -store.push({ - data: { - id: 1, - type: 'post', - relationships: { - category: { - data: { id: 1, type: 'category' } - } - } - }, - included: [ - { - id: 1, - type: 'category', - attributes: { - label: 'UNCATEGORIZED' - } - } - ] -}); - -// retrieve DS.Model instance of category for the first time (it is only an InternalModel before the next line) -post.get("category"); - -// Category#didInitData , { label: "uncategorized" } -// OR -// Category#didInitData , { label: "UNCATEGORIZED" } -```