import { guidFor } from '@ember/object/internals';
import { get } from '@ember/object';
import { relationshipStateFor, implicitRelationshipStateFor } from '../../record-data-for';
import { assert, warn } from '@ember/debug';
import OrderedSet from '../../ordered-set';
import _normalizeLink from '../../normalize-link';
import { RelationshipRecordData } from '../../ts-interfaces/relationship-record-data';
import { JsonApiRelationship } from '@ember-data/store/-private/ts-interfaces/record-data-json-api';
import { RelationshipSchema } from '@ember-data/store/-private/ts-interfaces/record-data-schemas';
import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features';
import { PaginationLinks } from '@ember-data/store/-private/ts-interfaces/ember-data-json-api';
/**
@module @ember-data/store
*/
interface ImplicitRelationshipMeta {
key?: string;
kind?: string;
options: any;
}
export default class Relationship {
inverseIsAsync: boolean | undefined;
kind?: string;
recordData: RelationshipRecordData;
members: OrderedSet<RelationshipRecordData>;
canonicalMembers: OrderedSet<RelationshipRecordData>;
store: any;
key: string | null;
inverseKey: string | null;
isAsync: boolean;
isPolymorphic: boolean;
relationshipMeta: ImplicitRelationshipMeta | RelationshipSchema;
inverseKeyForImplicit: string;
meta: any;
__inverseMeta: any;
_tempModelName: string;
shouldForceReload: boolean = false;
relationshipIsStale: boolean;
hasDematerializedInverse: boolean;
hasAnyRelationshipData: boolean;
relationshipIsEmpty: boolean;
hasFailedLoadAttempt: boolean = false;
links?: PaginationLinks;
willSync?: boolean;
constructor(
store: any,
inverseKey: string | null,
relationshipMeta: ImplicitRelationshipMeta,
recordData: RelationshipRecordData,
inverseIsAsync?: boolean
) {
this.inverseIsAsync = inverseIsAsync;
this.kind = relationshipMeta.kind;
let async = relationshipMeta.options.async;
let polymorphic = relationshipMeta.options.polymorphic;
this.recordData = recordData;
this.members = new OrderedSet();
this.canonicalMembers = new OrderedSet();
this.store = store;
this.key = relationshipMeta.key || null;
this.inverseKey = inverseKey;
this.isAsync = typeof async === 'undefined' ? true : async;
this.isPolymorphic = typeof polymorphic === 'undefined' ? false : polymorphic;
this.relationshipMeta = relationshipMeta;
//This probably breaks for polymorphic relationship in complex scenarios, due to
//multiple possible modelNames
this.inverseKeyForImplicit = this._tempModelName + this.key;
this.meta = null;
this.__inverseMeta = undefined;
/*
This flag forces fetch. `true` for a single request once `reload()`
has been called `false` at all other times.
*/
// this.shouldForceReload = false;
/*
This flag indicates whether we should
re-fetch the relationship the next time
it is accessed.
The difference between this flag and `shouldForceReload`
is in how we treat the presence of partially missing data:
- for a forced reload, we will reload the link or EVERY record
- for a stale reload, we will reload the link (if present) else only MISSING records
Ideally these flags could be merged, but because we don't give the
request layer the option of deciding how to resolve the data being queried
we are forced to differentiate for now.
It is also possible for a relationship to remain stale after a forced reload; however,
in this case `hasFailedLoadAttempt` ought to be `true`.
false when
=> recordData.isNew() on initial setup
=> a previously triggered request has resolved
=> we get relationship data via push
true when
=> !recordData.isNew() on initial setup
=> an inverse has been unloaded
=> we get a new link for the relationship
TODO @runspired unskip the acceptance tests and fix these flags
*/
this.relationshipIsStale = false;
/*
This flag indicates whether we should
**partially** re-fetch the relationship the
next time it is accessed.
false when
=> initial setup
=> a previously triggered request has resolved
true when
=> an inverse has been unloaded
*/
this.hasDematerializedInverse = false;
/*
This flag indicates whether we should consider the content
of this relationship "known".
If we have no relationship knowledge, and the relationship
is `async`, we will attempt to fetch the relationship on
access if it is also stale.
Snapshot uses this to tell the difference between unknown
(`undefined`) or empty (`null`). The reason for this is that
we wouldn't want to serialize unknown relationships as `null`
as that might overwrite remote state.
All relationships for a newly created (`store.createRecord()`) are
considered known (`hasAnyRelationshipData === true`).
true when
=> we receive a push with either new data or explicit empty (`[]` or `null`)
=> the relationship is a belongsTo and we have received data from
the other side.
false when
=> we have received no signal about what data belongs in this relationship
=> the relationship is a hasMany and we have only received data from
the other side.
*/
this.hasAnyRelationshipData = false;
/*
Flag that indicates whether an empty relationship is explicitly empty
(signaled by push giving us an empty array or null relationship)
e.g. an API response has told us that this relationship is empty.
Thus far, it does not appear that we actually need this flag; however,
@runspired has found it invaluable when debugging relationship tests
to determine whether (and why if so) we are in an incorrect state.
true when
=> we receive a push with explicit empty (`[]` or `null`)
=> we have received no signal about what data belongs in this relationship
=> on initial create (as no signal is known yet)
false at all other times
*/
this.relationshipIsEmpty = true;
/*
Flag def here for reference, defined as getter in has-many.js / belongs-to.js
true when
=> hasAnyRelationshipData is true
AND
=> members (NOT canonicalMembers) @each !isEmpty
TODO, consider changing the conditional here from !isEmpty to !hiddenFromRecordArrays
*/
// TODO do we want this anymore? Seems somewhat useful
// especially if we rename to `hasUpdatedLink`
// which would tell us slightly more about why the
// relationship is stale
// this.updatedLink = false;
}
get isNew(): boolean {
return this.recordData.isNew();
}
_inverseIsAsync(): boolean {
return !!this.inverseIsAsync;
}
_inverseIsSync(): boolean {
return !!(this.inverseKey && !this.inverseIsAsync);
}
_hasSupportForImplicitRelationships(recordData: RelationshipRecordData): boolean {
return recordData._implicitRelationships !== undefined && recordData._implicitRelationships !== null;
}
_hasSupportForRelationships(recordData: RelationshipRecordData): recordData is RelationshipRecordData {
return recordData._relationships !== undefined && recordData._relationships !== null;
}
get _inverseMeta(): RelationshipSchema {
if (this.__inverseMeta === undefined) {
let inverseMeta = null;
if (this.inverseKey) {
// We know we have a full inverse relationship
let type = (this.relationshipMeta as RelationshipSchema).type;
let inverseModelClass = this.store.modelFor(type);
let inverseRelationships = get(inverseModelClass, 'relationshipsByName');
inverseMeta = inverseRelationships.get(this.inverseKey);
}
this.__inverseMeta = inverseMeta;
}
return this.__inverseMeta;
}
recordDataDidDematerialize() {
const inverseKey = this.inverseKey;
if (!inverseKey) {
return;
}
// we actually want a union of members and canonicalMembers
// they should be disjoint but currently are not due to a bug
this.forAllMembers(inverseRecordData => {
if (!this._hasSupportForRelationships(inverseRecordData)) {
return;
}
let relationship = relationshipStateFor(inverseRecordData, inverseKey);
let belongsToRelationship = inverseRecordData.getBelongsTo(inverseKey)._relationship;
// For canonical members, it is possible that inverseRecordData has already been associated to
// to another record. For such cases, do not dematerialize the inverseRecordData
if (
!belongsToRelationship ||
!belongsToRelationship.inverseRecordData ||
this.recordData === belongsToRelationship.inverseRecordData
) {
relationship.inverseDidDematerialize(this.recordData);
}
});
}
forAllMembers(callback: (im: RelationshipRecordData) => void) {
let seen = Object.create(null);
for (let i = 0; i < this.members.list.length; i++) {
const inverseInternalModel = this.members.list[i];
const id = guidFor(inverseInternalModel);
if (!seen[id]) {
seen[id] = true;
callback(inverseInternalModel);
}
}
for (let i = 0; i < this.canonicalMembers.list.length; i++) {
const inverseInternalModel = this.canonicalMembers.list[i];
const id = guidFor(inverseInternalModel);
if (!seen[id]) {
seen[id] = true;
callback(inverseInternalModel);
}
}
}
inverseDidDematerialize(inverseRecordData: RelationshipRecordData | null) {
if (!this.isAsync || (inverseRecordData && inverseRecordData.isNew())) {
// unloading inverse of a sync relationship is treated as a client-side
// delete, so actually remove the models don't merely invalidate the cp
// cache.
// if the record being unloaded only exists on the client, we similarly
// treat it as a client side delete
this.removeRecordDataFromOwn(inverseRecordData);
this.removeCanonicalRecordDataFromOwn(inverseRecordData);
this.setRelationshipIsEmpty(true);
} else {
this.setHasDematerializedInverse(true);
}
}
updateMeta(meta: any) {
this.meta = meta;
}
clear() {
let members = this.members.list;
while (members.length > 0) {
let member = members[0];
this.removeRecordData(member);
}
let canonicalMembers = this.canonicalMembers.list;
while (canonicalMembers.length > 0) {
let member = canonicalMembers[0];
this.removeCanonicalRecordData(member);
}
}
removeAllRecordDatasFromOwn() {
this.setRelationshipIsStale(true);
this.members.clear();
}
removeAllCanonicalRecordDatasFromOwn() {
this.canonicalMembers.clear();
this.flushCanonicalLater();
}
removeRecordDatas(recordDatas: RelationshipRecordData[]) {
recordDatas.forEach(recordData => this.removeRecordData(recordData));
}
addRecordDatas(recordDatas: RelationshipRecordData[], idx?: number) {
recordDatas.forEach(recordData => {
this.addRecordData(recordData, idx);
if (idx !== undefined) {
idx++;
}
});
}
addCanonicalRecordDatas(recordDatas: RelationshipRecordData[], idx: number) {
for (let i = 0; i < recordDatas.length; i++) {
if (idx !== undefined) {
this.addCanonicalRecordData(recordDatas[i], i + idx);
} else {
this.addCanonicalRecordData(recordDatas[i]);
}
}
}
addCanonicalRecordData(recordData: RelationshipRecordData, idx?: number) {
if (!this.canonicalMembers.has(recordData)) {
this.canonicalMembers.add(recordData);
this.setupInverseRelationship(recordData);
}
this.flushCanonicalLater();
this.setHasAnyRelationshipData(true);
}
setupInverseRelationship(recordData: RelationshipRecordData) {
if (this.inverseKey) {
if (!this._hasSupportForRelationships(recordData)) {
return;
}
let relationship = relationshipStateFor(recordData, this.inverseKey);
// if we have only just initialized the inverse relationship, then it
// already has this.recordData in its canonicalMembers, so skip the
// unnecessary work. The exception to this is polymorphic
// relationships whose members are determined by their inverse, as those
// relationships cannot efficiently find their inverse payloads.
relationship.addCanonicalRecordData(this.recordData);
} else {
if (!this._hasSupportForImplicitRelationships(recordData)) {
return;
}
let relationships = recordData._implicitRelationships;
let relationship = relationships[this.inverseKeyForImplicit];
if (!relationship) {
relationship = relationships[this.inverseKeyForImplicit] = new Relationship(
this.store,
this.key,
{ options: { async: this.isAsync } },
recordData
);
}
relationship.addCanonicalRecordData(this.recordData);
}
}
removeCanonicalRecordDatas(recordDatas: RelationshipRecordData[], idx?: number) {
for (let i = 0; i < recordDatas.length; i++) {
if (idx !== undefined) {
this.removeCanonicalRecordData(recordDatas[i], i + idx);
} else {
this.removeCanonicalRecordData(recordDatas[i]);
}
}
}
removeCanonicalRecordData(recordData: RelationshipRecordData, idx?: number) {
if (this.canonicalMembers.has(recordData)) {
this.removeCanonicalRecordDataFromOwn(recordData);
if (this.inverseKey) {
this.removeCanonicalRecordDataFromInverse(recordData);
} else {
if (
this._hasSupportForImplicitRelationships(recordData) &&
recordData._implicitRelationships[this.inverseKeyForImplicit]
) {
recordData._implicitRelationships[this.inverseKeyForImplicit].removeCanonicalRecordData(this.recordData);
}
}
}
this.flushCanonicalLater();
}
addRecordData(recordData: RelationshipRecordData, idx?: number) {
if (!this.members.has(recordData)) {
this.members.addWithIndex(recordData, idx);
this.notifyRecordRelationshipAdded(recordData, idx);
if (this._hasSupportForRelationships(recordData) && this.inverseKey) {
relationshipStateFor(recordData, this.inverseKey).addRecordData(this.recordData);
} else {
if (this._hasSupportForImplicitRelationships(recordData)) {
if (!recordData._implicitRelationships[this.inverseKeyForImplicit]) {
recordData._implicitRelationships[this.inverseKeyForImplicit] = new Relationship(
this.store,
this.key,
{ options: { async: this.isAsync } },
recordData,
this.isAsync
);
}
recordData._implicitRelationships[this.inverseKeyForImplicit].addRecordData(this.recordData);
}
}
}
this.setHasAnyRelationshipData(true);
}
removeRecordData(recordData: RelationshipRecordData) {
if (this.members.has(recordData)) {
this.removeRecordDataFromOwn(recordData);
if (this.inverseKey) {
this.removeRecordDataFromInverse(recordData);
} else {
if (
this._hasSupportForImplicitRelationships(recordData) &&
recordData._implicitRelationships[this.inverseKeyForImplicit]
) {
recordData._implicitRelationships[this.inverseKeyForImplicit].removeRecordData(this.recordData);
}
}
}
}
removeRecordDataFromInverse(recordData: RelationshipRecordData) {
if (!this._hasSupportForRelationships(recordData)) {
return;
}
if (this.inverseKey) {
let inverseRelationship = relationshipStateFor(recordData, this.inverseKey);
//Need to check for existence, as the record might unloading at the moment
if (inverseRelationship) {
inverseRelationship.removeRecordDataFromOwn(this.recordData);
}
}
}
removeRecordDataFromOwn(recordData: RelationshipRecordData | null, idx?: number) {
this.members.delete(recordData);
}
removeCanonicalRecordDataFromInverse(recordData: RelationshipRecordData) {
if (!this._hasSupportForRelationships(recordData)) {
return;
}
if (this.inverseKey) {
let inverseRelationship = relationshipStateFor(recordData, this.inverseKey);
//Need to check for existence, as the record might unloading at the moment
if (inverseRelationship) {
inverseRelationship.removeCanonicalRecordDataFromOwn(this.recordData);
}
}
}
removeCanonicalRecordDataFromOwn(recordData: RelationshipRecordData | null, idx?: number) {
this.canonicalMembers.delete(recordData);
this.flushCanonicalLater();
}
/*
Call this method once a record deletion has been persisted
to purge it from BOTH current and canonical state of all
relationships.
@method removeCompletelyFromInverse
@private
*/
removeCompletelyFromInverse() {
if (!this.inverseKey && !this.inverseKeyForImplicit) {
return;
}
// we actually want a union of members and canonicalMembers
// they should be disjoint but currently are not due to a bug
let seen = Object.create(null);
const recordData = this.recordData;
let unload;
if (this.inverseKey) {
unload = inverseRecordData => {
const id = guidFor(inverseRecordData);
if (this._hasSupportForRelationships(inverseRecordData) && seen[id] === undefined) {
if (this.inverseKey) {
const relationship = relationshipStateFor(inverseRecordData, this.inverseKey);
relationship.removeCompletelyFromOwn(recordData);
}
seen[id] = true;
}
};
} else {
unload = inverseRecordData => {
const id = guidFor(inverseRecordData);
if (this._hasSupportForImplicitRelationships(inverseRecordData) && seen[id] === undefined) {
const relationship = implicitRelationshipStateFor(inverseRecordData, this.inverseKeyForImplicit);
relationship.removeCompletelyFromOwn(recordData);
seen[id] = true;
}
};
}
this.members.forEach(unload);
this.canonicalMembers.forEach(unload);
if (!this.isAsync) {
this.clear();
}
}
/*
Removes the given RecordData from BOTH canonical AND current state.
This method is useful when either a deletion or a rollback on a new record
needs to entirely purge itself from an inverse relationship.
*/
removeCompletelyFromOwn(recordData: RelationshipRecordData) {
this.canonicalMembers.delete(recordData);
this.members.delete(recordData);
}
flushCanonical() {
let list = this.members.list as RelationshipRecordData[];
this.willSync = false;
//a hack for not removing new RecordDatas
//TODO remove once we have proper diffing
let newRecordDatas: RelationshipRecordData[] = [];
for (let i = 0; i < list.length; i++) {
// TODO Igor deal with this
if (list[i].isNew()) {
newRecordDatas.push(list[i]);
}
}
//TODO(Igor) make this less abysmally slow
this.members = this.canonicalMembers.copy();
for (let i = 0; i < newRecordDatas.length; i++) {
this.members.add(newRecordDatas[i]);
}
}
flushCanonicalLater() {
if (this.willSync) {
return;
}
this.willSync = true;
// Reaching back into the store to use ED's runloop
this.store._updateRelationshipState(this);
}
updateLinks(links: PaginationLinks): void {
this.links = links;
}
updateRecordDatasFromAdapter(recordDatas?: RelationshipRecordData[]) {
this.setHasAnyRelationshipData(true);
//TODO(Igor) move this to a proper place
//TODO Once we have adapter support, we need to handle updated and canonical changes
this.computeChanges(recordDatas);
}
computeChanges(recordDatas?: RelationshipRecordData[]) {}
notifyRecordRelationshipAdded(recordData?, idxs?) {}
setHasAnyRelationshipData(value: boolean) {
this.hasAnyRelationshipData = value;
}
setHasDematerializedInverse(value: boolean) {
this.hasDematerializedInverse = value;
}
setRelationshipIsStale(value: boolean) {
this.relationshipIsStale = value;
}
setRelationshipIsEmpty(value: boolean) {
this.relationshipIsEmpty = value;
}
setShouldForceReload(value: boolean) {
this.shouldForceReload = value;
}
setHasFailedLoadAttempt(value: boolean) {
this.hasFailedLoadAttempt = value;
}
/*
`push` for a relationship allows the store to push a JSON API Relationship
Object onto the relationship. The relationship will then extract and set the
meta, data and links of that relationship.
`push` use `updateMeta`, `updateData` and `updateLink` to update the state
of the relationship.
*/
push(payload: JsonApiRelationship, initial?: boolean) {
let hasRelationshipDataProperty = false;
let hasLink = false;
if (payload.meta) {
this.updateMeta(payload.meta);
}
if (payload.data !== undefined) {
hasRelationshipDataProperty = true;
this.updateData(payload.data, initial);
} else if (this.isAsync === false && !this.hasAnyRelationshipData) {
hasRelationshipDataProperty = true;
let data = this.kind === 'hasMany' ? [] : null;
this.updateData(data, initial);
}
if (payload.links) {
let originalLinks = this.links;
this.updateLinks(payload.links);
if (payload.links.related) {
let relatedLink = _normalizeLink(payload.links.related);
let currentLink = originalLinks && originalLinks.related ? _normalizeLink(originalLinks.related) : null;
let currentLinkHref = currentLink ? currentLink.href : null;
if (relatedLink && relatedLink.href && relatedLink.href !== currentLinkHref) {
warn(
`You pushed a record of type '${this.recordData.modelName}' with a relationship '${this.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`,
this.isAsync || this.hasAnyRelationshipData,
{
id: 'ds.store.push-link-for-sync-relationship',
}
);
assert(
`You have pushed a record of type '${this.recordData.modelName}' with '${this.key}' as a link, but the value of that link is not a string.`,
typeof relatedLink.href === 'string' || relatedLink.href === null
);
hasLink = true;
}
}
}
/*
Data being pushed into the relationship might contain only data or links,
or a combination of both.
IF contains only data
IF contains both links and data
relationshipIsEmpty -> true if is empty array (has-many) or is null (belongs-to)
hasAnyRelationshipData -> true
hasDematerializedInverse -> false
relationshipIsStale -> false
allInverseRecordsAreLoaded -> run-check-to-determine
IF contains only links
relationshipIsStale -> true
*/
this.setHasFailedLoadAttempt(false);
if (hasRelationshipDataProperty) {
let relationshipIsEmpty = payload.data === null || (Array.isArray(payload.data) && payload.data.length === 0);
this.setHasAnyRelationshipData(true);
this.setRelationshipIsStale(false);
this.setHasDematerializedInverse(false);
this.setRelationshipIsEmpty(relationshipIsEmpty);
} else if (hasLink) {
this.setRelationshipIsStale(true);
if (!initial) {
let recordData = this.recordData;
let storeWrapper = this.recordData.storeWrapper;
if (CUSTOM_MODEL_CLASS) {
storeWrapper.notifyBelongsToChange(recordData.modelName, recordData.id, recordData.clientId, this.key!);
} else {
storeWrapper.notifyPropertyChange(
recordData.modelName,
recordData.id,
recordData.clientId,
// We know we are not an implicit relationship here
this.key!
);
}
}
}
}
localStateIsEmpty() {}
updateData(payload?, initial?) {}
destroy() {}
}