diff --git a/packages/model/src/-private/model.js b/packages/model/src/-private/model.js index 2be725691bc..c0bde310c73 100644 --- a/packages/model/src/-private/model.js +++ b/packages/model/src/-private/model.js @@ -101,6 +101,10 @@ function computeOnce(target, propertyName, desc) { } ``` + Models are used both to define the static schema for a + particular resource type as well as the class to instantiate + to present that data from cache. + @class Model @public @extends Ember.EmberObject @@ -863,60 +867,46 @@ class Model extends EmberObject { /** Get the reference for the specified belongsTo relationship. - Example + For instance, given the following model - ```app/models/blog.js + ```app/models/blog-post.js import Model, { belongsTo } from '@ember-data/model'; - export default class BlogModel extends Model { - @belongsTo('user', { async: true, inverse: null }) user; + export default class BlogPost extends Model { + @belongsTo('user', { async: true, inverse: null }) author; } ``` - ```javascript - let blog = store.push({ - data: { - type: 'blog', - id: 1, - relationships: { - user: { - data: { type: 'user', id: 1 } - } - } - } - }); - let userRef = blog.belongsTo('user'); + Then the reference for the author relationship would be + retrieved from a record instance like so: - // check if the user relationship is loaded - let isLoaded = userRef.value() !== null; + ```js + blogPost.belongsTo('author'); + ``` - // get the record of the reference (null if not yet available) - let user = userRef.value(); + A `BelongsToReference` is a low-level API that allows access + and manipulation of a belongsTo relationship. - // get the identifier of the reference - if (userRef.remoteType() === "id") { - let id = userRef.id(); - } else if (userRef.remoteType() === "link") { - let link = userRef.link(); - } + It is especially useful when you're dealing with `async` relationships + as it allows synchronous access to the relationship data if loaded, as + well as APIs for loading, reloading the data or accessing available + information without triggering a load. - // load user (via store.findRecord or store.findBelongsTo) - userRef.load().then(...) + It may also be useful when using `sync` relationships that need to be + loaded/reloaded with more precise timing than marking the + relationship as `async` and relying on autofetch would have allowed. - // or trigger a reload - userRef.reload().then(...) + However,keep in mind that marking a relationship as `async: false` will introduce + bugs into your application if the data is not always guaranteed to be available + by the time the relationship is accessed. Ergo, it is recommended when using this + approach to utilize `links` for unloaded relationship state instead of identifiers. - // provide data for reference - userRef.push({ - type: 'user', - id: 1, - attributes: { - username: "@user" - } - }).then(function(user) { - userRef.value() === user; - }); - ``` + Reference APIs are entangled with the relationship's underlying state, + thus any getters or cached properties that utilize these will properly + invalidate if the relationship state changes. + + References are "stable", meaning that multiple calls to retrieve the reference + for a given relationship will always return the same HasManyReference. @method belongsTo @public @@ -928,55 +918,46 @@ class Model extends EmberObject { /** Get the reference for the specified hasMany relationship. - Example + For instance, given the following model - ```app/models/blog.js + ```app/models/blog-post.js import Model, { hasMany } from '@ember-data/model'; - export default class BlogModel extends Model { + export default class BlogPost extends Model { @hasMany('comment', { async: true, inverse: null }) comments; } + ``` - let blog = store.push({ - data: { - type: 'blog', - id: 1, - relationships: { - comments: { - data: [ - { type: 'comment', id: 1 }, - { type: 'comment', id: 2 } - ] - } - } - } - }); - let commentsRef = blog.hasMany('comments'); + Then the reference for the comments relationship would be + retrieved from a record instance like so: - // check if the comments are loaded already - let isLoaded = commentsRef.value() !== null; + ```js + blogPost.hasMany('comments'); + ``` - // get the records of the reference (null if not yet available) - let comments = commentsRef.value(); + A `HasManyReference` is a low-level API that allows access + and manipulation of a hasMany relationship. - // get the identifier of the reference - if (commentsRef.remoteType() === "ids") { - let ids = commentsRef.ids(); - } else if (commentsRef.remoteType() === "link") { - let link = commentsRef.link(); - } + It is especially useful when you are dealing with `async` relationships + as it allows synchronous access to the relationship data if loaded, as + well as APIs for loading, reloading the data or accessing available + information without triggering a load. - // load comments (via store.findMany or store.findHasMany) - commentsRef.load().then(...) + It may also be useful when using `sync` relationships with `@ember-data/model` + that need to be loaded/reloaded with more precise timing than marking the + relationship as `async` and relying on autofetch would have allowed. - // or trigger a reload - commentsRef.reload().then(...) + However,keep in mind that marking a relationship as `async: false` will introduce + bugs into your application if the data is not always guaranteed to be available + by the time the relationship is accessed. Ergo, it is recommended when using this + approach to utilize `links` for unloaded relationship state instead of identifiers. - // provide data for reference - commentsRef.push([{ type: 'comment', id: 1 }, { type: 'comment', id: 2 }]).then(function(comments) { - commentsRef.value() === comments; - }); - ``` + Reference APIs are entangled with the relationship's underlying state, + thus any getters or cached properties that utilize these will properly + invalidate if the relationship state changes. + + References are "stable", meaning that multiple calls to retrieve the reference + for a given relationship will always return the same HasManyReference. @method hasMany @public diff --git a/packages/model/src/-private/references/belongs-to.ts b/packages/model/src/-private/references/belongs-to.ts index f40d758fda1..d448923e04e 100644 --- a/packages/model/src/-private/references/belongs-to.ts +++ b/packages/model/src/-private/references/belongs-to.ts @@ -2,12 +2,12 @@ import { DEBUG } from '@ember-data/env'; import type { ResourceEdge } from '@ember-data/graph/-private/edges/resource'; import type { Graph } from '@ember-data/graph/-private/graph'; import type Store from '@ember-data/store'; -import { recordIdentifierFor } from '@ember-data/store/-private'; import type { NotificationType } from '@ember-data/store/-private/managers/notification-manager'; import type { RecordInstance } from '@ember-data/store/-types/q/record-instance'; import { cached, compat } from '@ember-data/tracking'; import { defineSignal } from '@ember-data/tracking/-private'; import type { StableRecordIdentifier } from '@warp-drive/core-types'; +import type { StableExistingRecordIdentifier } from '@warp-drive/core-types/identifier'; import type { LinkObject, Links, @@ -19,6 +19,7 @@ import type { import { assertPolymorphicType } from '../debug/assert-polymorphic-type'; import type { LegacySupport } from '../legacy-relationships-support'; import { areAllInverseRecordsLoaded, LEGACY_SUPPORT } from '../legacy-relationships-support'; +import { isMaybeResource } from './has-many'; /** @module @ember-data/model @@ -38,23 +39,56 @@ function isResourceIdentiferWithRelatedLinks( } /** - A `BelongsToReference` is a low-level API that allows users and - addon authors to perform meta-operations on a belongs-to - relationship. + A `BelongsToReference` is a low-level API that allows access + and manipulation of a belongsTo relationship. + + It is especially useful when you're dealing with `async` relationships + from `@ember-data/model` as it allows synchronous access to + the relationship data if loaded, as well as APIs for loading, reloading + the data or accessing available information without triggering a load. + + It may also be useful when using `sync` relationships with `@ember-data/model` + that need to be loaded/reloaded with more precise timing than marking the + relationship as `async` and relying on autofetch would have allowed. + + However,keep in mind that marking a relationship as `async: false` will introduce + bugs into your application if the data is not always guaranteed to be available + by the time the relationship is accessed. Ergo, it is recommended when using this + approach to utilize `links` for unloaded relationship state instead of identifiers. + + Reference APIs are entangled with the relationship's underlying state, + thus any getters or cached properties that utilize these will properly + invalidate if the relationship state changes. + + References are "stable", meaning that multiple calls to retrieve the reference + for a given relationship will always return the same HasManyReference. @class BelongsToReference @public */ export default class BelongsToReference { - declare key: string; + declare graph: Graph; + declare store: Store; declare belongsToRelationship: ResourceEdge; + /** + * The field name on the parent record for this has-many relationship. + * + * @property {String} key + * @public + */ + declare key: string; + + /** + * The type of resource this relationship will contain. + * + * @property {String} type + * @public + */ declare type: string; - declare ___identifier: StableRecordIdentifier; - declare store: Store; - declare graph: Graph; // unsubscribe tokens given to us by the notification manager declare ___token: object; + declare ___identifier: StableRecordIdentifier; declare ___relatedToken: object | null; declare _ref: number; @@ -277,7 +311,7 @@ export default class BelongsToReference { @public @return {Object} The meta information for the belongs-to relationship. */ - meta() { + meta(): Meta | null { let meta: Meta | null = null; const resource = this._resource(); if (resource && resource.meta && typeof resource.meta === 'object') { @@ -343,11 +377,12 @@ export default class BelongsToReference { } /** - `push` can be used to update the data in the relationship and Ember - Data will treat the new data as the canonical value of this - relationship on the backend. + `push` can be used to update the data in the relationship and EmberData + will treat the new data as the canonical value of this relationship on + the backend. A value of `null` (e.g. `{ data: null }`) can be passed to + clear the relationship. - Example + Example model ```app/models/blog.js import Model, { belongsTo } from '@ember-data/model'; @@ -355,63 +390,123 @@ export default class BelongsToReference { export default class BlogModel extends Model { @belongsTo('user', { async: true, inverse: null }) user; } + ``` - let blog = store.push({ + Setup some initial state, note we haven't loaded the user yet: + + ```js + const blog = store.push({ data: { type: 'blog', - id: 1, + id: '1', relationships: { user: { - data: { type: 'user', id: 1 } + data: { type: 'user', id: '1' } } } } - }); - let userRef = blog.belongsTo('user'); + }); - // provide data for reference - userRef.push({ + const userRef = blog.belongsTo('user'); + userRef.id(); // '1' + ``` + + Update the state using `push`, note we can do this even without + having loaded the user yet by providing a resource-identifier. + + Both full a resource and a resource-identifier are supported. + + ```js + await userRef.push({ data: { type: 'user', - id: 1, - attributes: { - username: "@user" - } + id: '2', } - }).then(function(user) { - userRef.value() === user; }); + + userRef.id(); // '2' ``` + You may also pass in links and meta fore the relationship, and sideload + additional resources that might be required. + + ```js + await userRef.push({ + data: { + type: 'user', + id: '2', + }, + links: { + related: '/articles/1/author' + }, + meta: { + lastUpdated: Date.now() + }, + included: [ + { + type: 'user-preview', + id: '2', + attributes: { + username: '@runspired' + } + } + ] + }); + ``` + + By default, the store will attempt to fetch the record if it is not loaded or its + resource data is not included in the call to `push` before resolving the returned + promise with the new state.. + + Alternatively, pass `true` as the second argument to avoid fetching unloaded records + and instead the promise will resolve with void without attempting to fetch. This is + particularly useful if you want to update the state of the relationship without + forcing the load of all of the associated record. + @method push - @public - @param {Object} object a JSONAPI document object describing the new value of this relationship. - @return {Promise} A promise that resolves with the new value in this belongs-to relationship. - */ - push(data: SingleResourceDocument | Promise): Promise { - const jsonApiDoc: SingleResourceDocument = data as SingleResourceDocument; - const record = this.store.push(jsonApiDoc); + @public + @param {Object} doc a JSONAPI document object describing the new value of this relationship. + @param {Boolean} [skipFetch] if `true`, do not attempt to fetch unloaded records + @return {Promise} + */ + async push(doc: SingleResourceDocument, skipFetch?: boolean): Promise { + const { store } = this; + const isResourceData = doc.data && isMaybeResource(doc.data); + const added = isResourceData + ? (store._push(doc, true) as StableExistingRecordIdentifier) + : doc.data + ? (store.identifierCache.getOrCreateRecordIdentifier(doc.data) as StableExistingRecordIdentifier) + : null; + const { identifier } = this.belongsToRelationship; if (DEBUG) { - assertPolymorphicType( - this.belongsToRelationship.identifier, - this.belongsToRelationship.definition, - recordIdentifierFor(record), - this.store - ); + if (added) { + assertPolymorphicType(identifier, this.belongsToRelationship.definition, added, store); + } } - const { identifier } = this.belongsToRelationship; - this.store._join(() => { + const newData: SingleResourceRelationship = {}; + + // only set data if it was passed in + if (doc.data || doc.data === null) { + newData.data = added; + } + if ('links' in doc) { + newData.links = doc.links; + } + if ('meta' in doc) { + newData.meta = doc.meta; + } + store._join(() => { this.graph.push({ - op: 'replaceRelatedRecord', + op: 'updateRelationship', record: identifier, field: this.key, - value: recordIdentifierFor(record), + value: newData, }); }); - return Promise.resolve(record); + if (!skipFetch) return this.load(); } /** @@ -531,7 +626,7 @@ export default class BelongsToReference { @param {Object} options the options to pass in. @return {Promise} a promise that resolves with the record in this belongs-to relationship. */ - load(options?: Record) { + async load(options?: Record): Promise { const support: LegacySupport = (LEGACY_SUPPORT as Map).get( this.___identifier )!; @@ -539,7 +634,9 @@ export default class BelongsToReference { !this.belongsToRelationship.definition.isAsync && !areAllInverseRecordsLoaded(this.store, this._resource()); return fetchSyncRel ? support.reloadBelongsTo(this.key, options).then(() => this.value()) - : support.getBelongsTo(this.key, options); + : // we cast to fix the return type since typescript and eslint don't understand async functions + // properly + (support.getBelongsTo(this.key, options) as Promise); } /** diff --git a/packages/model/src/-private/references/has-many.ts b/packages/model/src/-private/references/has-many.ts index 68dafe31a3b..a8676acb123 100644 --- a/packages/model/src/-private/references/has-many.ts +++ b/packages/model/src/-private/references/has-many.ts @@ -1,10 +1,10 @@ +import { assert } from '@ember/debug'; + import { DEBUG } from '@ember-data/env'; import type { CollectionEdge } from '@ember-data/graph/-private/edges/collection'; import type { Graph } from '@ember-data/graph/-private/graph'; import type Store from '@ember-data/store'; -import { recordIdentifierFor } from '@ember-data/store'; import type { NotificationType } from '@ember-data/store/-private/managers/notification-manager'; -import type { RecordInstance } from '@ember-data/store/-types/q/record-instance'; import type { FindOptions } from '@ember-data/store/-types/q/store'; import { cached, compat } from '@ember-data/tracking'; import { defineSignal } from '@ember-data/tracking/-private'; @@ -17,7 +17,6 @@ import type { LinkObject, Meta, PaginationLinks, - SingleResourceDocument, } from '@warp-drive/core-types/spec/raw'; import { assertPolymorphicType } from '../debug/assert-polymorphic-type'; @@ -41,19 +40,52 @@ function isResourceIdentiferWithRelatedLinks( return Boolean(value && value.links && value.links.related); } /** - A `HasManyReference` is a low-level API that allows users and addon - authors to perform meta-operations on a has-many relationship. + A `HasManyReference` is a low-level API that allows access + and manipulation of a hasMany relationship. + + It is especially useful when you're dealing with `async` relationships + from `@ember-data/model` as it allows synchronous access to + the relationship data if loaded, as well as APIs for loading, reloading + the data or accessing available information without triggering a load. + + It may also be useful when using `sync` relationships with `@ember-data/model` + that need to be loaded/reloaded with more precise timing than marking the + relationship as `async` and relying on autofetch would have allowed. + + However,keep in mind that marking a relationship as `async: false` will introduce + bugs into your application if the data is not always guaranteed to be available + by the time the relationship is accessed. Ergo, it is recommended when using this + approach to utilize `links` for unloaded relationship state instead of identifiers. + + Reference APIs are entangled with the relationship's underlying state, + thus any getters or cached properties that utilize these will properly + invalidate if the relationship state changes. + + References are "stable", meaning that multiple calls to retrieve the reference + for a given relationship will always return the same HasManyReference. @class HasManyReference @public - @extends Reference */ export default class HasManyReference { declare graph: Graph; - declare key: string; + declare store: Store; declare hasManyRelationship: CollectionEdge; + /** + * The field name on the parent record for this has-many relationship. + * + * @property {String} key + * @public + */ + declare key: string; + + /** + * The type of resource this relationship will contain. + * + * @property {String} type + * @public + */ declare type: string; - declare store: Store; // unsubscribe tokens given to us by the notification manager ___token!: object; @@ -88,6 +120,11 @@ export default class HasManyReference { // TODO inverse } + /** + * This method should never be called by user code. + * + * @internal + */ destroy() { this.store.notifications.unsubscribe(this.___token); this.___relatedTokenMap.forEach((token) => { @@ -339,9 +376,9 @@ export default class HasManyReference { @method meta @public - @return {Object} The meta information for the belongs-to relationship. + @return {Object|null} The meta information for the belongs-to relationship. */ - meta() { + meta(): Meta | null { let meta: Meta | null = null; const resource = this._resource(); if (resource && resource.meta && typeof resource.meta === 'object') { @@ -351,11 +388,12 @@ export default class HasManyReference { } /** - `push` can be used to update the data in the relationship and Ember - Data will treat the new data as the canonical value of this - relationship on the backend. + `push` can be used to update the data in the relationship and EmberData + will treat the new data as the canonical value of this relationship on + the backend. An empty array will signify the canonical value should be + empty. - Example + Example model ```app/models/post.js import Model, { hasMany } from '@ember-data/model'; @@ -365,79 +403,138 @@ export default class HasManyReference { } ``` - ``` - let post = store.push({ + Setup some initial state, note we haven't loaded the comments yet: + + ```js + const post = store.push({ data: { type: 'post', - id: 1, + id: '1', relationships: { comments: { - data: [{ type: 'comment', id: 1 }] + data: [{ type: 'comment', id: '1' }] } } } }); - let commentsRef = post.hasMany('comments'); - + const commentsRef = post.hasMany('comments'); commentsRef.ids(); // ['1'] + ``` + + Update the state using `push`, note we can do this even without + having loaded these comments yet by providing resource identifiers. + + Both full resources and resource identifiers are supported. - commentsRef.push([ - [{ type: 'comment', id: 2 }], - [{ type: 'comment', id: 3 }], - ]) + ```js + await commentsRef.push({ + data: [ + { type: 'comment', id: '2' }, + { type: 'comment', id: '3' }, + ] + }); commentsRef.ids(); // ['2', '3'] ``` + For convenience, you can also pass in an array of resources or resource identifiers + without wrapping them in the `data` property: + + ```js + await commentsRef.push([ + { type: 'comment', id: '4' }, + { type: 'comment', id: '5' }, + ]); + + commentsRef.ids(); // ['4', '5'] + ``` + + When using the `data` property, you may also include other resource data via included, + as well as provide new links and meta to the relationship. + + ```js + await commentsRef.push({ + links: { + related: '/posts/1/comments' + }, + meta: { + total: 2 + }, + data: [ + { type: 'comment', id: '4' }, + { type: 'comment', id: '5' }, + ], + included: [ + { type: 'other-thing', id: '1', attributes: { foo: 'bar' }, + ] + }); + ``` + + By default, the store will attempt to fetch any unloaded records before resolving + the returned promise with the ManyArray. + + Alternatively, pass `true` as the second argument to avoid fetching unloaded records + and instead the promise will resolve with void without attempting to fetch. This is + particularly useful if you want to update the state of the relationship without + forcing the load of all of the associated records. + @method push - @public - @param {Array|Promise} objectOrPromise a promise that resolves to a JSONAPI document object describing the new value of this relationship. - @return {ManyArray} - */ + @public + @param {Array|Object} doc a JSONAPI document object describing the new value of this relationship. + @param {Boolean} [skipFetch] if `true`, do not attempt to fetch unloaded records + @return {Promise} + */ async push( - objectOrPromise: ExistingResourceObject[] | CollectionResourceDocument | { data: SingleResourceDocument[] } - ): Promise { - const payload = objectOrPromise; - let array: Array; - - if (!Array.isArray(payload) && typeof payload === 'object' && Array.isArray(payload.data)) { - array = payload.data; - } else { - array = payload as ExistingResourceObject[]; - } - + doc: ExistingResourceObject[] | CollectionResourceDocument, + skipFetch?: boolean + ): Promise { const { store } = this; + const dataDoc = Array.isArray(doc) ? { data: doc } : doc; + const isResourceData = Array.isArray(dataDoc.data) && dataDoc.data.length > 0 && isMaybeResource(dataDoc.data[0]); - const identifiers = array.map((obj) => { - let record: RecordInstance; - if ('data' in obj) { - // TODO deprecate pushing non-valid JSON:API here - record = store.push(obj); - } else { - record = store.push({ data: obj }); - } + // enforce that one of links, meta or data is present + assert( + `You must provide at least one of 'links', 'meta' or 'data' when calling hasManyReference.push`, + 'links' in dataDoc || 'meta' in dataDoc || 'data' in dataDoc + ); - if (DEBUG) { - const relationshipMeta = this.hasManyRelationship.definition; - const identifier = this.hasManyRelationship.identifier; + const identifiers = !Array.isArray(dataDoc.data) + ? [] + : isResourceData + ? (store._push(dataDoc, true) as StableRecordIdentifier[]) + : dataDoc.data.map((i) => store.identifierCache.getOrCreateRecordIdentifier(i)); + const { identifier } = this.hasManyRelationship; - assertPolymorphicType(identifier, relationshipMeta, recordIdentifierFor(record), store); - } - return recordIdentifierFor(record); - }); + if (DEBUG) { + const relationshipMeta = this.hasManyRelationship.definition; - const { identifier } = this.hasManyRelationship; + identifiers.forEach((added) => { + assertPolymorphicType(identifier, relationshipMeta, added, store); + }); + } + + const newData: CollectionResourceRelationship = {}; + // only set data if it was passed in + if (Array.isArray(dataDoc.data)) { + newData.data = identifiers; + } + if ('links' in dataDoc) { + newData.links = dataDoc.links; + } + if ('meta' in dataDoc) { + newData.meta = dataDoc.meta; + } store._join(() => { this.graph.push({ - op: 'replaceRelatedRecords', + op: 'updateRelationship', record: identifier, field: this.key, - value: identifiers, + value: newData, }); }); - return this.load(); + if (!skipFetch) return this.load(); } _isLoaded() { @@ -494,7 +591,7 @@ export default class HasManyReference { @public @return {ManyArray} */ - value() { + value(): ManyArray | null { const support: LegacySupport = (LEGACY_SUPPORT as Map).get( this.___identifier )!; @@ -570,7 +667,7 @@ export default class HasManyReference { ``` @method load - @public + @public @param {Object} options the options to pass in. @return {Promise} a promise that resolves with the ManyArray in this has-many relationship. @@ -583,7 +680,9 @@ export default class HasManyReference { !this.hasManyRelationship.definition.isAsync && !areAllInverseRecordsLoaded(this.store, this._resource()); return fetchSyncRel ? (support.reloadHasMany(this.key, options) as Promise) - : (support.getHasMany(this.key, options) as Promise | ManyArray); // this cast is necessary because typescript does not work properly with custom thenables; + : // we cast to fix the return type since typescript and eslint don't understand async functions + // properly + (support.getHasMany(this.key, options) as Promise | ManyArray); } /** @@ -644,3 +743,8 @@ export default class HasManyReference { } } defineSignal(HasManyReference.prototype, '_ref', 0); + +export function isMaybeResource(object: ExistingResourceObject | ResourceIdentifier): object is ExistingResourceObject { + const keys = Object.keys(object).filter((k) => k !== 'id' && k !== 'type' && k !== 'lid'); + return keys.length > 0; +} diff --git a/packages/store/src/-private/legacy-model-support/record-reference.ts b/packages/store/src/-private/legacy-model-support/record-reference.ts index 299d976b8f8..40485d4dc87 100644 --- a/packages/store/src/-private/legacy-model-support/record-reference.ts +++ b/packages/store/src/-private/legacy-model-support/record-reference.ts @@ -21,7 +21,6 @@ import type Store from '../store-service'; @class RecordReference @public - @extends Reference */ export default class RecordReference { declare store: Store; diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 7bdafe17bfb..35b39b8f115 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -272,6 +272,8 @@ module.exports = { '(public) @ember-data/model @ember-data/model#hasMany', '(public) @ember-data/model BelongsToReference#id', '(public) @ember-data/model BelongsToReference#identifier', + '(public) @ember-data/model BelongsToReference#key', + '(public) @ember-data/model BelongsToReference#type', '(public) @ember-data/model BelongsToReference#link', '(public) @ember-data/model BelongsToReference#links', '(public) @ember-data/model BelongsToReference#load', @@ -291,6 +293,8 @@ module.exports = { '(public) @ember-data/model Errors#remove', '(public) @ember-data/model HasManyReference#identifiers', '(public) @ember-data/model HasManyReference#ids', + '(public) @ember-data/model HasManyReference#key', + '(public) @ember-data/model HasManyReference#type', '(public) @ember-data/model HasManyReference#link', '(public) @ember-data/model HasManyReference#links', '(public) @ember-data/model HasManyReference#load', diff --git a/tests/docs/package.json b/tests/docs/package.json index f42d9478cba..a8be22df51f 100644 --- a/tests/docs/package.json +++ b/tests/docs/package.json @@ -12,7 +12,7 @@ "author": "", "scripts": { "test:docs": "qunit ./index.js", - "lint": "eslint . --quiet --cache --cache-strategy=content --ext .js,.ts,.mjs,.cjs --report-unused-disable-directives", + "lint": "eslint . --quiet --cache --cache-strategy=content --ext .js,.ts,.mjs,.cjs", "_syncPnpm": "bun run sync-dependencies-meta-injected" }, "devDependencies": { diff --git a/tests/main/tests/integration/references/belongs-to-test.js b/tests/main/tests/integration/references/belongs-to-test.js index 0470ae7d651..6c8f5ccf264 100644 --- a/tests/main/tests/integration/references/belongs-to-test.js +++ b/tests/main/tests/integration/references/belongs-to-test.js @@ -9,31 +9,31 @@ import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; import JSONAPISerializer from '@ember-data/serializer/json-api'; import testInDebug from '@ember-data/unpublished-test-infra/test-support/test-in-debug'; -module('integration/references/belongs-to', function (hooks) { - setupTest(hooks); +class Family extends Model { + @hasMany('person', { async: true, inverse: 'family' }) persons; + @attr name; +} - hooks.beforeEach(function () { - const Family = Model.extend({ - persons: hasMany('person', { async: true, inverse: 'family' }), - name: attr(), - }); +class Team extends Model { + @hasMany('person', { async: true, inverse: 'team' }) persons; + @attr name; +} - const Team = Model.extend({ - persons: hasMany('person', { async: true, inverse: 'team' }), - name: attr(), - }); +class Person extends Model { + @belongsTo('family', { async: true, inverse: 'persons' }) family; + @belongsTo('team', { async: false, inverse: 'persons' }) team; +} - const Person = Model.extend({ - family: belongsTo('family', { async: true, inverse: 'persons' }), - team: belongsTo('team', { async: false, inverse: 'persons' }), - }); +module('integration/references/belongs-to', function (hooks) { + setupTest(hooks); + hooks.beforeEach(function () { this.owner.register('model:family', Family); this.owner.register('model:team', Team); this.owner.register('model:person', Person); - this.owner.register('adapter:application', JSONAPIAdapter.extend()); - this.owner.register('serializer:application', class extends JSONAPISerializer {}); + this.owner.register('adapter:application', JSONAPIAdapter); + this.owner.register('serializer:application', JSONAPISerializer); }); testInDebug("record#belongsTo asserts when specified relationship doesn't exist", function (assert) { @@ -137,7 +137,131 @@ module('integration/references/belongs-to', function (hooks) { assert.deepEqual(familyReference.meta(), { foo: true }); }); - test('push(object)', async function (assert) { + test('push(object) works with resources', async function (assert) { + const store = this.owner.lookup('service:store'); + const Family = store.modelFor('family'); + + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + + const data = { + data: { + type: 'family', + id: '1', + attributes: { + name: 'Coreleone', + }, + }, + }; + + const record = await familyReference.push(data); + assert.true(record instanceof Family, 'push resolves with the referenced record'); + assert.strictEqual(record.name, 'Coreleone', 'name is set'); + }); + + test('push(object) works with resource identifiers (skipLoad: false)', async function (assert) { + const store = this.owner.lookup('service:store'); + + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + data: { type: 'family', id: '1' }, + }, + }, + }, + included: [ + { + type: 'family', + id: '2', + attributes: { + name: 'Don Coreleone', + }, + }, + ], + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.id(), '1', 'id is correct'); + + const record = await familyReference.push({ + data: { + type: 'family', + id: '2', + }, + }); + assert.strictEqual(familyReference.id(), '2', 'id is correct'); + assert.strictEqual(record.name, 'Don Coreleone', 'name is correct'); + }); + + test('push(object) works with resource identifiers (skipLoad: true)', async function (assert) { + const store = this.owner.lookup('service:store'); + + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.id(), '1', 'id is correct'); + + await familyReference.push( + { + data: { + type: 'family', + id: '2', + }, + }, + true + ); + assert.strictEqual(familyReference.id(), '2', 'id is correct'); + }); + + test('push(object) works with null data', async function (assert) { + const store = this.owner.lookup('service:store'); + + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.id(), '1', 'id is correct'); + + await familyReference.push({ + data: null, + }); + assert.strictEqual(familyReference.id(), null, 'id is correct'); + }); + + test('push(object) works with links', async function (assert) { const store = this.owner.lookup('service:store'); const Family = store.modelFor('family'); @@ -147,6 +271,81 @@ module('integration/references/belongs-to', function (hooks) { id: '1', relationships: { family: { + links: { related: '/person/1/families' }, + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.remoteType(), 'link', 'remoteType is link'); + assert.strictEqual(familyReference.link(), '/person/1/families', 'initial link is correct'); + + const data = { + links: { + related: '/person/1/families?page=1', + }, + data: { + type: 'family', + id: '1', + attributes: { + name: 'Coreleone', + }, + }, + }; + + const record = await familyReference.push(data); + assert.true(record instanceof Family, 'push resolves with the referenced record'); + assert.strictEqual(record.name, 'Coreleone', 'name is set'); + assert.strictEqual(familyReference.link(), '/person/1/families?page=1', 'link is updated'); + }); + + test('push(object) works with links even when data is not present', async function (assert) { + const store = this.owner.lookup('service:store'); + + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + links: { related: '/person/1/families' }, + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.remoteType(), 'link', 'remoteType is link'); + assert.strictEqual(familyReference.link(), '/person/1/families', 'initial link is correct'); + assert.strictEqual(familyReference.id(), '1', 'id is correct'); + + const data = { + links: { + related: '/person/1/families?page=1', + }, + }; + + await familyReference.push(data, true); + assert.strictEqual(familyReference.id(), '1', 'id is still correct'); + assert.strictEqual(familyReference.link(), '/person/1/families?page=1', 'link is updated'); + }); + + test('push(object) works with meta', async function (assert) { + const store = this.owner.lookup('service:store'); + const Family = store.modelFor('family'); + const timestamp1 = Date.now(); + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + meta: { + createdAt: timestamp1, + }, data: { type: 'family', id: '1' }, }, }, @@ -154,8 +353,13 @@ module('integration/references/belongs-to', function (hooks) { }); const familyReference = person.belongsTo('family'); + assert.deepEqual(familyReference.meta(), { createdAt: timestamp1 }, 'initial meta is correct'); + const timestamp2 = Date.now() + 1; const data = { + meta: { + updatedAt: timestamp2, + }, data: { type: 'family', id: '1', @@ -166,8 +370,43 @@ module('integration/references/belongs-to', function (hooks) { }; const record = await familyReference.push(data); - assert.ok(Family.detectInstance(record), 'push resolves with the referenced record'); - assert.strictEqual(get(record, 'name'), 'Coreleone', 'name is set'); + assert.true(record instanceof Family, 'push resolves with the referenced record'); + assert.strictEqual(record.name, 'Coreleone', 'name is set'); + assert.deepEqual(familyReference.meta(), { updatedAt: timestamp2 }, 'meta is updated'); + }); + + test('push(object) works with meta even when data is not present', async function (assert) { + const store = this.owner.lookup('service:store'); + const timestamp1 = Date.now(); + const person = store.push({ + data: { + type: 'person', + id: '1', + relationships: { + family: { + meta: { + createdAt: timestamp1, + }, + data: { type: 'family', id: '1' }, + }, + }, + }, + }); + + const familyReference = person.belongsTo('family'); + assert.strictEqual(familyReference.id(), '1', 'id is correct'); + assert.deepEqual(familyReference.meta(), { createdAt: timestamp1 }, 'initial meta is correct'); + + const timestamp2 = Date.now() + 1; + const data = { + meta: { + updatedAt: timestamp2, + }, + }; + + await familyReference.push(data, true); + assert.strictEqual(familyReference.id(), '1', 'id is still correct'); + assert.deepEqual(familyReference.meta(), { updatedAt: timestamp2 }, 'meta is updated'); }); testInDebug('push(object) asserts for invalid modelClass', async function (assert) { @@ -232,12 +471,18 @@ module('integration/references/belongs-to', function (hooks) { data: { type: 'person', id: '1', + attributes: { + name: 'Vito', + }, }, }); const mafiaFamily = { data: { type: 'mafia-family', id: '1', + attributes: { + name: 'Don', + }, }, }; @@ -245,7 +490,7 @@ module('integration/references/belongs-to', function (hooks) { const family = await familyReference.push(mafiaFamily); const record = store.peekRecord('mafia-family', '1'); - assert.strictEqual(family, record); + assert.strictEqual(family, record, 'we get back the correct record'); }); test('value() is null when reference is not yet loaded', function (assert) { diff --git a/tests/main/tests/integration/references/has-many-test.js b/tests/main/tests/integration/references/has-many-test.js index 76127007a1c..4c4ccc3b29b 100755 --- a/tests/main/tests/integration/references/has-many-test.js +++ b/tests/main/tests/integration/references/has-many-test.js @@ -252,8 +252,8 @@ module('integration/references/has-many', function (hooks) { const personsReference = family.hasMany('persons'); const data = [ - { data: { type: 'person', id: '1', attributes: { name: 'Vito' } } }, - { data: { type: 'person', id: '2', attributes: { name: 'Michael' } } }, + { type: 'person', id: '1', attributes: { name: 'Vito' } }, + { type: 'person', id: '2', attributes: { name: 'Michael' } }, ]; const records = await personsReference.push(data); @@ -290,7 +290,7 @@ module('integration/references/has-many', function (hooks) { const personsReference = family.hasMany('persons'); - const data = [{ data: { type: 'mafia-boss', id: '1', attributes: { name: 'Vito' } } }]; + const data = [{ type: 'mafia-boss', id: '1', attributes: { name: 'Vito' } }]; const records = await personsReference.push(data); assert.strictEqual(records.length, 1); @@ -318,19 +318,25 @@ module('integration/references/has-many', function (hooks) { const petsReference = person.hasMany('pets'); await assert.expectAssertion(async () => { - await petsReference.push([{ data: { type: 'person', id: '1' } }]); + await petsReference.push([{ type: 'person', id: '1' }]); }, "The 'person' type does not implement 'animal' and thus cannot be assigned to the 'pets' relationship in 'person'. If this relationship should be polymorphic, mark person.pets as `polymorphic: true` and person.owner as implementing it via `as: 'animal'`."); }); - testInDebug('push(object) supports legacy, non-JSON-API-conform payload', async function (assert) { + test('push valid json:api', async function (assert) { const store = this.owner.lookup('service:store'); - var family = store.push({ + const family = store.push({ data: { type: 'family', id: '1', relationships: { persons: { + links: { + related: '/families/1/persons', + }, + meta: { + total: 2, + }, data: [ { type: 'person', id: '1' }, { type: 'person', id: '2' }, @@ -339,23 +345,25 @@ module('integration/references/has-many', function (hooks) { }, }, }); - - var personsReference = family.hasMany('persons'); - - var payload = { + const personsReference = family.hasMany('persons'); + const payload = { data: [ - { data: { type: 'person', id: '1', attributes: { name: 'Vito' } } }, - { data: { type: 'person', id: '2', attributes: { name: 'Michael' } } }, + { type: 'person', id: '1', attributes: { name: 'Vito' } }, + { type: 'person', id: '2', attributes: { name: 'Michael' } }, ], }; + const pushResult = personsReference.push(payload); + assert.ok(pushResult.then, 'HasManyReference.push returns a promise'); - const records = await personsReference.push(payload); + const records = await pushResult; assert.strictEqual(records.length, 2); assert.strictEqual(records.at(0).name, 'Vito'); assert.strictEqual(records.at(1).name, 'Michael'); + assert.deepEqual(personsReference.meta(), { total: 2 }, 'meta is not updated'); + assert.strictEqual(personsReference.link(), '/families/1/persons', 'link is not updated'); }); - test('push valid json:api', async function (assert) { + test('push(document) can update links', async function (assert) { const store = this.owner.lookup('service:store'); const family = store.push({ @@ -364,6 +372,7 @@ module('integration/references/has-many', function (hooks) { id: '1', relationships: { persons: { + links: { related: '/families/1/persons' }, data: [ { type: 'person', id: '1' }, { type: 'person', id: '2' }, @@ -373,19 +382,124 @@ module('integration/references/has-many', function (hooks) { }, }); const personsReference = family.hasMany('persons'); - const payload = { - data: [ - { type: 'person', id: '1', attributes: { name: 'Vito' } }, - { type: 'person', id: '2', attributes: { name: 'Michael' } }, - ], - }; - const pushResult = personsReference.push(payload); - assert.ok(pushResult.then, 'HasManyReference.push returns a promise'); + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.strictEqual(personsReference.link(), '/families/1/persons', 'link is correct'); - const records = await pushResult; - assert.strictEqual(records.length, 2); - assert.strictEqual(records.at(0).name, 'Vito'); - assert.strictEqual(records.at(1).name, 'Michael'); + await personsReference.push( + { + links: { related: '/families/1/persons?page=1' }, + data: [ + { type: 'person', id: '3' }, + { type: 'person', id: '4' }, + ], + }, + true + ); + + assert.arrayStrictEquals(personsReference.ids(), ['3', '4'], 'ids are correct'); + assert.strictEqual(personsReference.link(), '/families/1/persons?page=1', 'link is correct'); + }); + test('push(document) can update links even when no data is present', async function (assert) { + const store = this.owner.lookup('service:store'); + + const family = store.push({ + data: { + type: 'family', + id: '1', + relationships: { + persons: { + links: { related: '/families/1/persons' }, + data: [ + { type: 'person', id: '1' }, + { type: 'person', id: '2' }, + ], + }, + }, + }, + }); + const personsReference = family.hasMany('persons'); + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.strictEqual(personsReference.link(), '/families/1/persons', 'link is correct'); + + await personsReference.push( + { + links: { related: '/families/1/persons?page=1' }, + }, + true + ); + + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.strictEqual(personsReference.link(), '/families/1/persons?page=1', 'link is correct'); + }); + test('push(document) can update meta', async function (assert) { + const store = this.owner.lookup('service:store'); + + const family = store.push({ + data: { + type: 'family', + id: '1', + relationships: { + persons: { + meta: { total: 2 }, + data: [ + { type: 'person', id: '1' }, + { type: 'person', id: '2' }, + ], + }, + }, + }, + }); + const personsReference = family.hasMany('persons'); + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.deepEqual(personsReference.meta(), { total: 2 }, 'meta is correct'); + + await personsReference.push( + { + meta: { total: 4 }, + data: [ + { type: 'person', id: '1' }, + { type: 'person', id: '2' }, + { type: 'person', id: '3' }, + { type: 'person', id: '4' }, + ], + }, + true + ); + + assert.arrayStrictEquals(personsReference.ids(), ['1', '2', '3', '4'], 'ids are correct'); + assert.deepEqual(personsReference.meta(), { total: 4 }, 'meta is correct'); + }); + test('push(document) can update meta even when no data is present', async function (assert) { + const store = this.owner.lookup('service:store'); + + const family = store.push({ + data: { + type: 'family', + id: '1', + relationships: { + persons: { + meta: { total: 2 }, + data: [ + { type: 'person', id: '1' }, + { type: 'person', id: '2' }, + ], + }, + }, + }, + }); + const personsReference = family.hasMany('persons'); + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.deepEqual(personsReference.meta(), { total: 2 }, 'meta is correct'); + + await personsReference.push( + { + meta: { total: 4 }, + }, + true + ); + + assert.arrayStrictEquals(personsReference.ids(), ['1', '2'], 'ids are correct'); + assert.deepEqual(personsReference.meta(), { total: 4 }, 'meta is correct'); }); test('value() returns null when reference is not yet loaded', function (assert) {