From 4a30273a02ac85a86994c478c425c8e11ef4faee Mon Sep 17 00:00:00 2001 From: pangratz Date: Wed, 10 Jun 2015 10:56:37 +0200 Subject: [PATCH] Implement RFC#57 - Reference Unification --- FEATURES.md | 2 + addon/-private/system/model/internal-model.js | 26 + addon/-private/system/model/model.js | 125 ++++ addon/-private/system/references.js | 5 + .../-private/system/references/belongs-to.js | 82 +++ addon/-private/system/references/has-many.js | 99 ++++ addon/-private/system/references/record.js | 39 ++ addon/-private/system/references/reference.js | 10 + .../system/relationships/state/belongs-to.js | 15 + addon/-private/system/store.js | 48 ++ config/features.json | 2 +- .../integration/references/belongs-to-test.js | 557 ++++++++++++++++++ tests/integration/references/has-many-test.js | 535 +++++++++++++++++ tests/integration/references/record-test.js | 237 ++++++++ .../relationships/belongs-to-test.js | 134 +++++ tests/unit/model-test.js | 12 +- 16 files changed, 1917 insertions(+), 11 deletions(-) create mode 100644 addon/-private/system/references.js create mode 100644 addon/-private/system/references/belongs-to.js create mode 100644 addon/-private/system/references/has-many.js create mode 100644 addon/-private/system/references/record.js create mode 100644 addon/-private/system/references/reference.js create mode 100644 tests/integration/references/belongs-to-test.js create mode 100644 tests/integration/references/has-many-test.js create mode 100644 tests/integration/references/record-test.js diff --git a/FEATURES.md b/FEATURES.md index 92cc95cdb0b..8e86c748d37 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -11,4 +11,6 @@ entry in `config/features.json`. ## Feature Flags +- `ds-references` + Adds references as described in [RFC 57](https://github.com/emberjs/rfcs/pull/57) diff --git a/addon/-private/system/model/internal-model.js b/addon/-private/system/model/internal-model.js index b28b2937a80..51efca00e4c 100644 --- a/addon/-private/system/model/internal-model.js +++ b/addon/-private/system/model/internal-model.js @@ -10,6 +10,12 @@ import { getOwner } from 'ember-data/-private/utils'; +import { + RecordReference, + BelongsToReference, + HasManyReference +} from "ember-data/-private/system/references"; + var Promise = Ember.RSVP.Promise; var get = Ember.get; var set = Ember.set; @@ -67,6 +73,8 @@ export default function InternalModel(type, id, store, _, data) { this._relationships = new Relationships(this); this._recordArrays = undefined; this.currentState = RootState.empty; + this.recordReference = new RecordReference(store, this); + this.references = {}; this.isReloading = false; this.isError = false; this.error = null; @@ -594,6 +602,24 @@ InternalModel.prototype = { return value; }, + referenceFor: function(type, name) { + var reference = this.references[name]; + + if (!reference) { + var relationship = this._relationships.get(name); + + if (type === "belongsTo") { + reference = new BelongsToReference(this.store, this, relationship); + } else if (type === "hasMany") { + reference = new HasManyReference(this.store, this, relationship); + } + + this.references[name] = reference; + } + + return reference; + }, + /** @method updateRecordArrays diff --git a/addon/-private/system/model/model.js b/addon/-private/system/model/model.js index 60cc0edbd45..5b447de30b5 100644 --- a/addon/-private/system/model/model.js +++ b/addon/-private/system/model/model.js @@ -2,6 +2,7 @@ import Ember from 'ember'; import { assert, deprecate } from "ember-data/-private/debug"; import { PromiseObject } from "ember-data/-private/system/promise-proxies"; import Errors from "ember-data/-private/system/model/errors"; +import isEnabled from 'ember-data/-private/features'; /** @module ember-data @@ -847,4 +848,128 @@ if (Ember.setOwner) { }); } +if (isEnabled("ds-references")) { + + Model.reopen({ + + /** + Get the reference for the specified belongsTo relationship. + + Example + + ```javascript + // models/blog.js + export default DS.Model.extend({ + user: DS.belongsTo({ async: true }) + }); + + store.push({ + type: 'blog', + id: 1, + relationships: { + user: { type: 'user', id: 1 } + } + }); + var userRef = blog.belongsTo('user'); + + // check if the user relationship is loaded + var isLoaded = userRef.value() !== null; + + // get the record of the reference (null if not yet available) + var user = userRef.value(); + + // get the identifier of the reference + if (userRef.remoteType() === "id") { + var id = userRef.id(); + } else if (userRef.remoteType() === "link") { + var link = userRef.link(); + } + + // load user (via store.find or store.findBelongsTo) + userRef.load().then(...) + + // or trigger a reload + userRef.reload().then(...) + + // provide data for reference + userRef.push({ + type: 'user', + id: 1, + attributes: { + username: "@user" + } + }).then(function(user) { + userRef.value() === user; + }); + ``` + + @method belongsTo + @param {String} name of the relationship + @return {BelongsToReference} reference for this relationship + */ + belongsTo: function(name) { + return this._internalModel.referenceFor('belongsTo', name); + }, + + /** + Get the reference for the specified hasMany relationship. + + Example + + ```javascript + // models/blog.js + export default DS.Model.extend({ + comments: DS.hasMany({ async: true }) + }); + + store.push({ + type: 'blog', + id: 1, + relationships: { + comments: { + data: [ + { type: 'comment', id: 1 }, + { type: 'comment', id: 2 } + ] + } + } + }); + var commentsRef = blog.hasMany('comments'); + + // check if the comments are loaded already + var isLoaded = commentsRef.value() !== null; + + // get the records of the reference (null if not yet available) + var comments = commentsRef.value(); + + // get the identifier of the reference + if (commentsRef.remoteType() === "ids") { + var ids = commentsRef.ids(); + } else if (commentsRef.remoteType() === "link") { + var link = commentsRef.link(); + } + + // load comments (via store.findMany or store.findHasMany) + commentsRef.load().then(...) + + // or trigger a reload + commentsRef.reload().then(...) + + // provide data for reference + commentsRef.push([{ type: 'comment', id: 1 }, { type: 'comment', id: 2 }]).then(function(comments) { + commentsRef.value() === comments; + }); + ``` + + @method hasMany + @param {String} name of the relationship + @return {HasManyReference} reference for this relationship + */ + hasMany: function(name) { + return this._internalModel.referenceFor('hasMany', name); + } + }); + +} + export default Model; diff --git a/addon/-private/system/references.js b/addon/-private/system/references.js new file mode 100644 index 00000000000..ad073f586ca --- /dev/null +++ b/addon/-private/system/references.js @@ -0,0 +1,5 @@ +import RecordReference from './references/record'; +import BelongsToReference from './references/belongs-to'; +import HasManyReference from './references/has-many'; + +export { RecordReference, BelongsToReference, HasManyReference }; diff --git a/addon/-private/system/references/belongs-to.js b/addon/-private/system/references/belongs-to.js new file mode 100644 index 00000000000..0d0a47d11e8 --- /dev/null +++ b/addon/-private/system/references/belongs-to.js @@ -0,0 +1,82 @@ +import Model from 'ember-data/-private/system/model'; +import Ember from 'ember'; +import Reference from './reference'; + +import { assertPolymorphicType } from "ember-data/-private/utils"; + +var BelongsToReference = function(store, parentInternalModel, belongsToRelationship) { + this._super$constructor(store, parentInternalModel); + this.belongsToRelationship = belongsToRelationship; + this.type = belongsToRelationship.relationshipMeta.type; + this.parent = parentInternalModel.recordReference; + + // TODO inverse +}; + +BelongsToReference.prototype = Object.create(Reference.prototype); +BelongsToReference.prototype.constructor = BelongsToReference; +BelongsToReference.prototype._super$constructor = Reference; + +BelongsToReference.prototype.remoteType = function() { + if (this.belongsToRelationship.link) { + return "link"; + } + + return "id"; +}; + +BelongsToReference.prototype.id = function() { + var inverseRecord = this.belongsToRelationship.inverseRecord; + return inverseRecord && inverseRecord.id; +}; + +BelongsToReference.prototype.link = function() { + return this.belongsToRelationship.link; +}; + +BelongsToReference.prototype.meta = function() { + return this.belongsToRelationship.meta; +}; + +BelongsToReference.prototype.push = function(objectOrPromise) { + return Ember.RSVP.resolve(objectOrPromise).then((data) => { + var record; + + if (data instanceof Model) { + record = data; + } else { + record = this.store.push(data); + } + + assertPolymorphicType(this.internalModel, this.belongsToRelationship.relationshipMeta, record._internalModel); + + this.belongsToRelationship.setCanonicalRecord(record._internalModel); + + return record; + }); +}; + +BelongsToReference.prototype.value = function() { + var inverseRecord = this.belongsToRelationship.inverseRecord; + return inverseRecord && inverseRecord.record; +}; + +BelongsToReference.prototype.load = function() { + if (this.remoteType() === "id") { + return this.belongsToRelationship.getRecord(); + } + + if (this.remoteType() === "link") { + return this.belongsToRelationship.findLink().then((internalModel) => { + return this.value(); + }); + } +}; + +BelongsToReference.prototype.reload = function() { + return this.belongsToRelationship.reload().then((internalModel) => { + return this.value(); + }); +}; + +export default BelongsToReference; diff --git a/addon/-private/system/references/has-many.js b/addon/-private/system/references/has-many.js new file mode 100644 index 00000000000..18e766cb6cb --- /dev/null +++ b/addon/-private/system/references/has-many.js @@ -0,0 +1,99 @@ +import Ember from 'ember'; +import Reference from './reference'; + +const get = Ember.get; + +var HasManyReference = function(store, parentInternalModel, hasManyRelationship) { + this._super$constructor(store, parentInternalModel); + this.hasManyRelationship = hasManyRelationship; + this.type = hasManyRelationship.relationshipMeta.type; + this.parent = parentInternalModel.recordReference; + + // TODO inverse +}; + +HasManyReference.prototype = Object.create(Reference.prototype); +HasManyReference.prototype.constructor = HasManyReference; +HasManyReference.prototype._super$constructor = Reference; + +HasManyReference.prototype.remoteType = function() { + if (this.hasManyRelationship.link) { + return "link"; + } + + return "ids"; +}; + +HasManyReference.prototype.link = function() { + return this.hasManyRelationship.link; +}; + +HasManyReference.prototype.ids = function() { + var members = this.hasManyRelationship.members; + var ids = members.toArray().map(function(internalModel) { + return internalModel.id; + }); + + return ids; +}; + +HasManyReference.prototype.meta = function() { + return this.hasManyRelationship.manyArray.meta; +}; + +HasManyReference.prototype.push = function(objectOrPromise) { + return Ember.RSVP.resolve(objectOrPromise).then((payload) => { + var array = payload; + if (typeof payload === "object" && payload.data) { + array = payload.data; + } + + var internalModels = array.map((obj) => { + var record = this.store.push(obj); + return record._internalModel; + }); + + // TODO add assertion for polymorphic type + + this.hasManyRelationship.computeChanges(internalModels); + + return this.hasManyRelationship.manyArray; + }); +}; + +HasManyReference.prototype._isLoaded = function() { + var hasData = get(this.hasManyRelationship, 'hasData'); + if (!hasData) { + return false; + } + + var members = this.hasManyRelationship.members.toArray(); + var isEveryLoaded = members.every(function(internalModel) { + return internalModel.isLoaded() === true; + }); + + return isEveryLoaded; +}; + +HasManyReference.prototype.value = function() { + if (this._isLoaded()) { + return this.hasManyRelationship.manyArray; + } + + return null; +}; + +HasManyReference.prototype.load = function() { + if (!this._isLoaded()) { + return this.hasManyRelationship.getRecords(); + } + + var manyArray = this.hasManyRelationship.manyArray; + return Ember.RSVP.resolve(manyArray); +}; + +HasManyReference.prototype.reload = function() { + return this.hasManyRelationship.reload(); +}; + +export default HasManyReference; diff --git a/addon/-private/system/references/record.js b/addon/-private/system/references/record.js new file mode 100644 index 00000000000..87885d7cdab --- /dev/null +++ b/addon/-private/system/references/record.js @@ -0,0 +1,39 @@ +import Ember from 'ember'; +import Reference from './reference'; + +var RecordReference = function(store, internalModel) { + this._super$constructor(store, internalModel); + this.type = internalModel.modelName; + this.id = internalModel.id; + this.remoteType = 'identity'; +}; + +RecordReference.prototype = Object.create(Reference.prototype); +RecordReference.prototype.constructor = RecordReference; +RecordReference.prototype._super$constructor = Reference; + +RecordReference.prototype.push = function(objectOrPromise) { + return Ember.RSVP.resolve(objectOrPromise).then((data) => { + var record = this.store.push(data); + return record; + }); +}; + +RecordReference.prototype.value = function() { + return this.internalModel.record; +}; + +RecordReference.prototype.load = function() { + return this.store.findRecord(this.type, this.id); +}; + +RecordReference.prototype.reload = function() { + var record = this.value(); + if (record) { + return record.reload(); + } + + return this.load(); +}; + +export default RecordReference; diff --git a/addon/-private/system/references/reference.js b/addon/-private/system/references/reference.js new file mode 100644 index 00000000000..5f3fbdae646 --- /dev/null +++ b/addon/-private/system/references/reference.js @@ -0,0 +1,10 @@ +var Reference = function(store, internalModel) { + this.store = store; + this.internalModel = internalModel; +}; + +Reference.prototype = { + constructor: Reference +}; + +export default Reference; diff --git a/addon/-private/system/relationships/state/belongs-to.js b/addon/-private/system/relationships/state/belongs-to.js index fc3638d1997..3ab114c2a0b 100644 --- a/addon/-private/system/relationships/state/belongs-to.js +++ b/addon/-private/system/relationships/state/belongs-to.js @@ -144,3 +144,18 @@ BelongsToRelationship.prototype.getRecord = function() { return toReturn; } }; + +BelongsToRelationship.prototype.reload = function() { + // TODO handle case when reload() is triggered multiple times + + if (this.link) { + return this.fetchLink(); + } + + // reload record, if it is already loaded + if (this.inverseRecord && this.inverseRecord.record) { + return this.inverseRecord.record.reload(); + } + + return this.findRecord(); +}; diff --git a/addon/-private/system/store.js b/addon/-private/system/store.js index 0b74380e0bd..9d9e5edb78d 100644 --- a/addon/-private/system/store.js +++ b/addon/-private/system/store.js @@ -56,6 +56,8 @@ import InternalModel from "ember-data/-private/system/model/internal-model"; import EmptyObject from "ember-data/-private/system/empty-object"; +import isEnabled from 'ember-data/-private/features'; + export let badIdFormatAssertion = '`id` has to be non-empty string or number'; var Backburner = Ember._Backburner || Ember.Backburner || Ember.__loader.require('backburner')['default'] || Ember.__loader.require('backburner')['Backburner']; @@ -2037,6 +2039,52 @@ Store = Service.extend({ }); +if (isEnabled("ds-references")) { + + Store.reopen({ + /** + Get the reference for the specified record. + + Example + + ```javascript + var userRef = store.getReference('user', 1); + + // check if the user is loaded + var isLoaded = userRef.value() !== null; + + // get the record of the reference (null if not yet available) + var user = userRef.value(); + + // get the identifier of the reference + if (userRef.remoteType() === "id") { + var id = userRef.id(); + } + + // load user (via store.find) + userRef.load().then(...) + + // or trigger a reload + userRef.reload().then(...) + + // provide data for reference + userRef.push({ id: 1, username: "@user" }).then(function(user) { + userRef.value() === user; + }); + ``` + + @method getReference + @param {String} type + @param {String|Integer} id + @return {RecordReference} + */ + getReference: function(type, id) { + return this._internalModelForId(type, id).recordReference; + } + }); + +} + function deserializeRecordId(store, key, relationship, id) { if (isNone(id)) { return; diff --git a/config/features.json b/config/features.json index 0db3279e44b..444c96da30d 100644 --- a/config/features.json +++ b/config/features.json @@ -1,3 +1,3 @@ { - + "ds-references": null } diff --git a/tests/integration/references/belongs-to-test.js b/tests/integration/references/belongs-to-test.js new file mode 100644 index 00000000000..83bc2c41f3a --- /dev/null +++ b/tests/integration/references/belongs-to-test.js @@ -0,0 +1,557 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import setupStore from 'dummy/tests/helpers/store'; +import { module, test } from 'qunit'; +import isEnabled from 'ember-data/-private/features'; + +if (isEnabled("ds-references")) { + + var get = Ember.get; + var run = Ember.run; + var env, Family; + + module("integration/references/belongs-to", { + beforeEach() { + Family = DS.Model.extend({ + persons: DS.hasMany(), + name: DS.attr() + }); + var Person = DS.Model.extend({ + family: DS.belongsTo({ async: true }) + }); + + env = setupStore({ + person: Person, + family: Family + }); + }, + + afterEach() { + run(env.container, 'destroy'); + } + }); + + test("record#belongsTo", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + assert.equal(familyReference.remoteType(), 'id'); + assert.equal(familyReference.type, 'family'); + assert.equal(familyReference.id(), 1); + }); + + test("record#belongsTo for a linked reference", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + links: { related: '/families/1' } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + assert.equal(familyReference.remoteType(), 'link'); + assert.equal(familyReference.type, 'family'); + assert.equal(familyReference.link(), "/families/1"); + }); + + test("BelongsToReference#parent is a reference to the parent where the relationship is defined", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var personReference = env.store.getReference('person', 1); + var familyReference = person.belongsTo('family'); + + assert.ok(personReference); + assert.equal(familyReference.parent, personReference); + }); + + test("BelongsToReference#meta() returns the most recent meta for the relationship", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + links: { + related: '/families/1' + }, + meta: { + foo: true + } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + assert.deepEqual(familyReference.meta(), { foo: true }); + }); + + test("push(object)", function(assert) { + var done = assert.async(); + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + var data = { + data: { + type: 'family', + id: 1, + attributes: { + name: "Coreleone" + } + } + }; + + familyReference.push(data).then(function(record) { + assert.ok(Family.detectInstance(record), "push resolves with the referenced record"); + assert.equal(get(record, 'name'), "Coreleone", "name is set"); + + done(); + }); + }); + }); + + test("push(record)", function(assert) { + var done = assert.async(); + + var person, family; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + family = env.store.push({ + data: { + type: 'family', + id: 1, + attributes: { + name: "Coreleone" + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + familyReference.push(family).then(function(record) { + assert.ok(Family.detectInstance(record), "push resolves with the referenced record"); + assert.equal(get(record, 'name'), "Coreleone", "name is set"); + assert.equal(record, family); + + done(); + }); + }); + }); + + test("push(promise)", function(assert) { + var done = assert.async(); + + var push; + var deferred = Ember.RSVP.defer(); + + run(function() { + var person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + var familyReference = person.belongsTo('family'); + push = familyReference.push(deferred.promise); + }); + + assert.ok(push.then, 'BelongsToReference.push returns a promise'); + + run(function() { + deferred.resolve({ + data: { + type: 'family', + id: 1, + attributes: { + name: "Coreleone" + } + } + }); + }); + + run(function() { + push.then(function(record) { + assert.ok(Family.detectInstance(record), "push resolves with the record"); + assert.equal(get(record, 'name'), "Coreleone", "name is updated"); + + done(); + }); + }); + }); + + test("push(record) asserts for invalid type", function(assert) { + var person, anotherPerson; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + anotherPerson = env.store.push({ + data: { + type: 'person', + id: 2 + } + }); + }); + + var familyReference = person.belongsTo('family'); + + assert.expectAssertion(function() { + run(function() { + familyReference.push(anotherPerson); + }); + }, "You cannot add a record of type 'person' to the 'person.family' relationship (only 'family' allowed)"); + }); + + test("push(record) works with polymorphic type", function(assert) { + var done = assert.async(); + + var person, mafiaFamily; + + env.registry.register('model:mafia-family', Family.extend()); + + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1 + } + }); + mafiaFamily = env.store.push({ + data: { + type: 'mafia-family', + id: 1 + } + }); + }); + + var familyReference = person.belongsTo('family'); + run(function() { + familyReference.push(mafiaFamily).then(function(family) { + assert.equal(family, mafiaFamily); + + done(); + }); + }); + }); + + test("value() is null when reference is not yet loaded", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + assert.equal(familyReference.value(), null); + }); + + test("value() returns the referenced record when loaded", function(assert) { + var person, family; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + family = env.store.push({ + data: { + type: 'family', + id: 1 + } + }); + }); + + var familyReference = person.belongsTo('family'); + assert.equal(familyReference.value(), family); + }); + + test("load() fetches the record", function(assert) { + var done = assert.async(); + + env.adapter.findRecord = function(store, type, id) { + return Ember.RSVP.resolve({ + id: 1, name: "Coreleone" + }); + }; + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + familyReference.load().then(function(record) { + assert.equal(get(record, 'name'), "Coreleone"); + + done(); + }); + }); + }); + + test("load() fetches link when remoteType is link", function(assert) { + var done = assert.async(); + + env.adapter.findBelongsTo = function(store, snapshot, link) { + assert.equal(link, "/families/1"); + + return Ember.RSVP.resolve({ + id: 1, name: "Coreleone" + }); + }; + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + links: { related: '/families/1' } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + assert.equal(familyReference.remoteType(), "link"); + + run(function() { + familyReference.load().then(function(record) { + assert.equal(get(record, 'name'), "Coreleone"); + + done(); + }); + }); + }); + + test("reload() - loads the record when not yet loaded", function(assert) { + var done = assert.async(); + + var count = 0; + env.adapter.findRecord = function(store, type, id) { + count++; + assert.equal(count, 1); + + return Ember.RSVP.resolve({ + id: 1, name: "Coreleone" + }); + }; + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + familyReference.reload().then(function(record) { + assert.equal(get(record, 'name'), "Coreleone"); + + done(); + }); + }); + }); + + test("reload() - reloads the record when already loaded", function(assert) { + var done = assert.async(); + + var count = 0; + env.adapter.findRecord = function(store, type, id) { + count++; + assert.equal(count, 1); + + return Ember.RSVP.resolve({ + id: 1, name: "Coreleone" + }); + }; + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + data: { type: 'family', id: 1 } + } + } + } + }); + env.store.push({ + data: { + type: 'family', + id: 1 + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + familyReference.reload().then(function(record) { + assert.equal(get(record, 'name'), "Coreleone"); + + done(); + }); + }); + }); + + test("reload() - uses link to reload record", function(assert) { + var done = assert.async(); + + env.adapter.findBelongsTo = function(store, snapshot, link) { + assert.equal(link, "/families/1"); + + return Ember.RSVP.resolve({ + id: 1, name: "Coreleone" + }); + }; + + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1, + relationships: { + family: { + links: { related: '/families/1' } + } + } + } + }); + }); + + var familyReference = person.belongsTo('family'); + + run(function() { + familyReference.reload().then(function(record) { + assert.equal(get(record, 'name'), "Coreleone"); + + done(); + }); + }); + }); + +} diff --git a/tests/integration/references/has-many-test.js b/tests/integration/references/has-many-test.js new file mode 100644 index 00000000000..af7946a277b --- /dev/null +++ b/tests/integration/references/has-many-test.js @@ -0,0 +1,535 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import setupStore from 'dummy/tests/helpers/store'; +import { module, test } from 'qunit'; +import isEnabled from 'ember-data/-private/features'; + +if (isEnabled("ds-references")) { + + var get = Ember.get; + var run = Ember.run; + var env, Person; + + module("integration/references/has-many", { + beforeEach() { + var Family = DS.Model.extend({ + persons: DS.hasMany({ async: true }) + }); + Person = DS.Model.extend({ + name: DS.attr(), + family: DS.belongsTo() + }); + env = setupStore({ + person: Person, + family: Family + }); + }, + + afterEach() { + run(env.container, 'destroy'); + } + }); + + test("record#hasMany", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + assert.equal(personsReference.remoteType(), 'ids'); + assert.equal(personsReference.type, 'person'); + assert.deepEqual(personsReference.ids(), ['1', '2']); + }); + + test("record#hasMany for linked references", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + links: { related: '/families/1/persons' } + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + assert.equal(personsReference.remoteType(), 'link'); + assert.equal(personsReference.type, 'person'); + assert.equal(personsReference.link(), '/families/1/persons'); + }); + + test("HasManyReference#parent is a reference to the parent where the relationship is defined", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var familyReference = env.store.getReference('family', 1); + var personsReference = family.hasMany('persons'); + + assert.ok(familyReference); + assert.equal(personsReference.parent, familyReference); + }); + + test("HasManyReference#meta() returns the most recent meta for the relationship", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + links: { related: '/families/1/persons' }, + meta: { + foo: true + } + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + assert.deepEqual(personsReference.meta(), { foo: true }); + }); + + test("push(array)", function(assert) { + var done = assert.async(); + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + run(function() { + var data = [ + { data: { type: 'person', id: 1, attributes: { name: "Vito" } } }, + { data: { type: 'person', id: 2, attributes: { name: "Michael" } } } + ]; + + personsReference.push(data).then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito"); + assert.equal(records.objectAt(1).get('name'), "Michael"); + + done(); + }); + }); + }); + + test("push(object) supports JSON-API payload", function(assert) { + var done = assert.async(); + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + run(function() { + var data = { + data: [ + { data: { type: 'person', id: 1, attributes: { name: "Vito" } } }, + { data: { type: 'person', id: 2, attributes: { name: "Michael" } } } + ] + }; + + personsReference.push(data).then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito"); + assert.equal(records.objectAt(1).get('name'), "Michael"); + + done(); + }); + }); + }); + + test("push(promise)", function(assert) { + var done = assert.async(); + + var push; + var deferred = Ember.RSVP.defer(); + + run(function() { + var family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + var personsReference = family.hasMany('persons'); + push = personsReference.push(deferred.promise); + }); + + assert.ok(push.then, 'HasManyReference.push returns a promise'); + + run(function() { + var data = [ + { data: { type: 'person', id: 1, attributes: { name: "Vito" } } }, + { data: { type: 'person', id: 2, attributes: { name: "Michael" } } } + ]; + deferred.resolve(data); + }); + + run(function() { + push.then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito"); + assert.equal(records.objectAt(1).get('name'), "Michael"); + + done(); + }); + }); + }); + + test("value() returns null when reference is not yet loaded", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + assert.equal(personsReference.value(), null); + }); + + test("value() returns the referenced records when all records are loaded", function(assert) { + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + env.store.push({ data: { type: 'person', id: 1, attributes: { name: "Vito" } } }); + env.store.push({ data: { type: 'person', id: 2, attributes: { name: "Michael" } } }); + }); + + var personsReference = family.hasMany('persons'); + var records = personsReference.value(); + assert.equal(get(records, 'length'), 2); + assert.equal(records.isEvery('isLoaded'), true); + }); + + test("load() fetches the referenced records", function(assert) { + var done = assert.async(); + + env.adapter.findMany = function(store, type, id) { + return Ember.RSVP.resolve([{ id: 1, name: "Vito" }, { id: 2, name: "Michael" }]); + }; + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + run(function() { + personsReference.load().then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito"); + assert.equal(records.objectAt(1).get('name'), "Michael"); + + done(); + }); + }); + }); + + test("load() fetches link when remoteType is link", function(assert) { + var done = assert.async(); + + env.adapter.findHasMany = function(store, snapshot, link) { + assert.equal(link, "/families/1/persons"); + + return Ember.RSVP.resolve([{ id: 1, name: "Vito" }, { id: 2, name: "Michael" }]); + }; + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + links: { related: '/families/1/persons' } + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + assert.equal(personsReference.remoteType(), "link"); + + run(function() { + personsReference.load().then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito"); + assert.equal(records.objectAt(1).get('name'), "Michael"); + + done(); + }); + }); + }); + + test("load() - only a single find is triggered", function(assert) { + var done = assert.async(); + + var deferred = Ember.RSVP.defer(); + var count = 0; + + env.adapter.findMany = function(store, type, id) { + count++; + assert.equal(count, 1); + + return deferred.promise; + }; + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + + run(function() { + personsReference.load(); + personsReference.load().then(function(records) { + assert.equal(get(records, 'length'), 2); + }); + }); + + run(function() { + deferred.resolve([{ id: 1, name: "Vito" }, { id: 2, name: "Michael" }]); + }); + + run(function() { + personsReference.load().then(function(records) { + assert.equal(get(records, 'length'), 2); + + done(); + }); + }); + }); + + test("reload()", function(assert) { + var done = assert.async(); + + env.adapter.findMany = function(store, type, id) { + return Ember.RSVP.resolve([ + { id: 1, name: "Vito Coreleone" }, + { id: 2, name: "Michael Coreleone" } + ]); + }; + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 } + ] + } + } + } + }); + env.store.push({ data: { type: 'person', id: 1, attributes: { name: "Vito" } } }); + env.store.push({ data: { type: 'person', id: 2, attributes: { name: "Michael" } } }); + }); + + var personsReference = family.hasMany('persons'); + + run(function() { + personsReference.reload().then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito Coreleone"); + assert.equal(records.objectAt(1).get('name'), "Michael Coreleone"); + + done(); + }); + }); + }); + + test("reload() fetches link when remoteType is link", function(assert) { + var done = assert.async(); + + var count = 0; + env.adapter.findHasMany = function(store, snapshot, link) { + count++; + assert.equal(link, "/families/1/persons"); + + if (count === 1) { + return Ember.RSVP.resolve([{ id: 1, name: "Vito" }, { id: 2, name: "Michael" }]); + } else { + return Ember.RSVP.resolve([ + { id: 1, name: "Vito Coreleone" }, + { id: 2, name: "Michael Coreleone" } + ]); + } + }; + + var family; + run(function() { + family = env.store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + links: { related: '/families/1/persons' } + } + } + } + }); + }); + + var personsReference = family.hasMany('persons'); + assert.equal(personsReference.remoteType(), "link"); + + run(function() { + personsReference.load().then(function() { + return personsReference.reload(); + }).then(function(records) { + assert.ok(records instanceof DS.ManyArray, "push resolves with the referenced records"); + assert.equal(get(records, 'length'), 2); + assert.equal(records.objectAt(0).get('name'), "Vito Coreleone"); + assert.equal(records.objectAt(1).get('name'), "Michael Coreleone"); + + done(); + }); + }); + }); + +} diff --git a/tests/integration/references/record-test.js b/tests/integration/references/record-test.js new file mode 100644 index 00000000000..3522d542463 --- /dev/null +++ b/tests/integration/references/record-test.js @@ -0,0 +1,237 @@ +import DS from 'ember-data'; +import Ember from 'ember'; +import setupStore from 'dummy/tests/helpers/store'; +import { module, test } from 'qunit'; +import isEnabled from 'ember-data/-private/features'; + +if (isEnabled("ds-references")) { + + var get = Ember.get; + var run = Ember.run; + var env, Person; + + module("integration/references/record", { + beforeEach() { + Person = DS.Model.extend({ + name: DS.attr() + }); + + env = setupStore({ + person: Person + }); + }, + + afterEach() { + run(env.store, 'unloadAll'); + run(env.container, 'destroy'); + } + }); + + test("a RecordReference can be retrieved via store.getReference(type, id)", function(assert) { + var recordReference = env.store.getReference('person', 1); + + assert.equal(recordReference.remoteType, 'identity'); + assert.equal(recordReference.type, 'person'); + assert.equal(recordReference.id, 1); + }); + + test("push(object)", function(assert) { + var done = assert.async(); + + var push; + var recordReference = env.store.getReference('person', 1); + + run(function() { + push = recordReference.push({ + data: { + type: 'person', + id: 1, + attributes: { + name: "le name" + } + } + }); + }); + + assert.ok(push.then, 'RecordReference.push returns a promise'); + + run(function() { + push.then(function(record) { + assert.ok(record instanceof Person, "push resolves with the record"); + assert.equal(get(record, 'name'), "le name"); + + done(); + }); + }); + }); + + test("push(promise)", function(assert) { + var done = assert.async(); + + var push; + var deferred = Ember.RSVP.defer(); + var recordReference = env.store.getReference('person', 1); + + run(function() { + push = recordReference.push(deferred.promise); + }); + + assert.ok(push.then, 'RecordReference.push returns a promise'); + + run(function() { + deferred.resolve({ + data: { + type: 'person', + id: 1, + attributes: { + name: "le name" + } + } + }); + }); + + run(function() { + push.then(function(record) { + assert.ok(record instanceof Person, "push resolves with the record"); + assert.equal(get(record, 'name'), "le name", "name is updated"); + + done(); + }); + }); + }); + + test("value() returns null when not yet loaded", function(assert) { + var recordReference = env.store.getReference('person', 1); + assert.equal(recordReference.value(), null); + }); + + test("value() returns the record when loaded", function(assert) { + var person; + run(function() { + person = env.store.push({ + data: { + type: 'person', + id: 1 + } + }); + }); + + var recordReference = env.store.getReference('person', 1); + assert.equal(recordReference.value(), person); + }); + + test("load() fetches the record", function(assert) { + var done = assert.async(); + + env.adapter.findRecord = function(store, type, id) { + return Ember.RSVP.resolve({ + id: 1, name: "Vito" + }); + }; + + var recordReference = env.store.getReference('person', 1); + + run(function() { + recordReference.load().then(function(record) { + assert.equal(get(record, 'name'), "Vito"); + done(); + }); + }); + }); + + test("load() only a single find is triggered", function(assert) { + var done = assert.async(); + + var deferred = Ember.RSVP.defer(); + var count = 0; + + env.adapter.shouldReloadRecord = function() { return false; }; + env.adapter.shouldBackgroundReloadRecord = function() { return false; }; + env.adapter.findRecord = function(store, type, id) { + count++; + assert.equal(count, 1); + + return deferred.promise; + }; + + var recordReference = env.store.getReference('person', 1); + + run(function() { + recordReference.load(); + recordReference.load().then(function(record) { + assert.equal(get(record, 'name'), "Vito"); + }); + }); + + run(function() { + deferred.resolve({ + id: 1, name: "Vito" + }); + }); + + run(function() { + recordReference.load().then(function(record) { + assert.equal(get(record, 'name'), "Vito"); + + done(); + }); + }); + }); + + test("reload() loads the record if not yet loaded", function(assert) { + var done = assert.async(); + + var count = 0; + env.adapter.findRecord = function(store, type, id) { + count++; + assert.equal(count, 1); + + return Ember.RSVP.resolve({ + id: 1, name: "Vito Coreleone" + }); + }; + + var recordReference = env.store.getReference('person', 1); + + run(function() { + recordReference.reload().then(function(record) { + assert.equal(get(record, 'name'), "Vito Coreleone"); + + done(); + }); + }); + }); + + test("reload() fetches the record", function(assert) { + var done = assert.async(); + + env.adapter.findRecord = function(store, type, id) { + return Ember.RSVP.resolve({ + id: 1, name: "Vito Coreleone" + }); + }; + + run(function() { + env.store.push({ + data: { + type: 'person', + id: 1, + attributes: { + name: 'Vito' + } + } + }); + }); + + var recordReference = env.store.getReference('person', 1); + + run(function() { + recordReference.reload().then(function(record) { + assert.equal(get(record, 'name'), "Vito Coreleone"); + + done(); + }); + }); + }); + +} diff --git a/tests/integration/relationships/belongs-to-test.js b/tests/integration/relationships/belongs-to-test.js index 66949a24a98..c451b096e4c 100644 --- a/tests/integration/relationships/belongs-to-test.js +++ b/tests/integration/relationships/belongs-to-test.js @@ -1121,3 +1121,137 @@ test("Updated related link should take precedence over local data", function(ass }); }); }); + +if (Ember.FEATURES.isEnabled('ds-references')) { + + test("A belongsTo relationship can be reloaded using the reference if it was fetched via link", function(assert) { + var done = assert.async(); + + Chapter.reopen({ + book: DS.belongsTo({ async: true }) + }); + + env.adapter.findRecord = function() { + return Ember.RSVP.resolve({ + id: 1, + links: { book: '/books/1' } + }); + }; + + env.adapter.findBelongsTo = function() { + return Ember.RSVP.resolve({ id: 1, name: "book title" }); + }; + + run(function() { + var chapter; + store.find('chapter', 1).then(function(_chapter) { + chapter = _chapter; + + return chapter.get('book'); + }).then(function(book) { + assert.equal(book.get('name'), "book title"); + + env.adapter.findBelongsTo = function() { + return Ember.RSVP.resolve({ id: 1, name: "updated book title" }); + }; + + return chapter.belongsTo('book').reload(); + }).then(function(book) { + assert.equal(book.get('name'), "updated book title"); + + done(); + }); + }); + }); + + test("A sync belongsTo relationship can be reloaded using a reference if it was fetched via id", function(assert) { + var done = assert.async(); + + Chapter.reopen({ + book: DS.belongsTo() + }); + + var chapter; + run(function() { + chapter = env.store.push({ + data: { + type: 'chapter', + id: 1, + relationships: { + book: { + data: { type: 'book', id: 1 } + } + } + } + }); + env.store.push({ + data: { + type: 'book', + id: 1, + attributes: { + name: "book title" + } + } + }); + }); + + env.adapter.findRecord = function() { + return Ember.RSVP.resolve({ id: 1, name: "updated book title" }); + }; + + run(function() { + var book = chapter.get('book'); + assert.equal(book.get('name'), "book title"); + + chapter.belongsTo('book').reload().then(function(book) { + assert.equal(book.get('name'), "updated book title"); + + done(); + }); + }); + }); + + test("A belongsTo relationship can be reloaded using a reference if it was fetched via id", function(assert) { + var done = assert.async(); + + Chapter.reopen({ + book: DS.belongsTo({ async: true }) + }); + + var chapter; + run(function() { + chapter = env.store.push({ + data: { + type: 'chapter', + id: 1, + relationships: { + book: { + data: { type: 'book', id: 1 } + } + } + } + }); + }); + + env.adapter.findRecord = function() { + return Ember.RSVP.resolve({ id: 1, name: "book title" }); + }; + + run(function() { + chapter.get('book').then(function(book) { + assert.equal(book.get('name'), "book title"); + + env.adapter.findRecord = function() { + return Ember.RSVP.resolve({ id: 1, name: "updated book title" }); + }; + + return chapter.belongsTo('book').reload(); + }).then(function(book) { + assert.equal(book.get('name'), "updated book title"); + + done(); + }); + }); + }); + +} diff --git a/tests/unit/model-test.js b/tests/unit/model-test.js index 54af7217039..7481316dfb8 100644 --- a/tests/unit/model-test.js +++ b/tests/unit/model-test.js @@ -415,8 +415,8 @@ test("a DS.Model can have a defaultValue without an attribute type", function(as assert.equal(get(tag, 'name'), "unknown", "the default value is found"); }); -test("Calling attr(), belongsTo() or hasMany() throws a warning", function(assert) { - assert.expect(3); +test("Calling attr() throws a warning", function(assert) { + assert.expect(1); var Person = DS.Model.extend({ name: DS.attr('string') @@ -432,14 +432,6 @@ test("Calling attr(), belongsTo() or hasMany() throws a warning", function(asser assert.throws(function() { person.attr(); }, /The `attr` method is not available on DS.Model, a DS.Snapshot was probably expected/, "attr() throws a warning"); - - assert.throws(function() { - person.belongsTo(); - }, /The `belongsTo` method is not available on DS.Model, a DS.Snapshot was probably expected/, "belongTo() throws a warning"); - - assert.throws(function() { - person.hasMany(); - }, /The `hasMany` method is not available on DS.Model, a DS.Snapshot was probably expected/, "hasMany() throws a warning"); }); });