From a408eaa423965b06bcc372e5ea882c04fe352722 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Fri, 12 Jul 2019 14:17:09 -0700 Subject: [PATCH] chore: encapsulate InternalModel access (#6246) --- packages/-ember-data/tests/unit/model-test.js | 2 +- .../addon/-private/{index.js => index.ts} | 0 .../store/addon/-private/system/coerce-id.ts | 5 +- .../-private/system/internal-model-map.ts | 11 +- .../-private/system/model/internal-model.ts | 38 +-- .../addon/-private/system/model/model.js | 7 +- .../-private/system/record-array-manager.js | 5 +- packages/store/addon/-private/system/store.ts | 263 ++++-------------- .../system/store/internal-model-factory.ts | 228 +++++++++++++++ .../system/store/record-data-store-wrapper.ts | 44 ++- .../ts-interfaces/record-data-json-api.ts | 16 +- 11 files changed, 367 insertions(+), 252 deletions(-) rename packages/store/addon/-private/{index.js => index.ts} (100%) create mode 100644 packages/store/addon/-private/system/store/internal-model-factory.ts diff --git a/packages/-ember-data/tests/unit/model-test.js b/packages/-ember-data/tests/unit/model-test.js index 7534bb1c29b..9c3b6a477ca 100644 --- a/packages/-ember-data/tests/unit/model-test.js +++ b/packages/-ember-data/tests/unit/model-test.js @@ -349,7 +349,7 @@ module('unit/model - Model', function(hooks) { assert.equal(person.get('id'), null, 'initial created model id should be null'); assert.equal(idChange, 0); - store._setRecordId(person._internalModel, 'john'); + person._internalModel.setId('john'); assert.equal(idChange, 1); assert.equal(person.get('id'), 'john', 'new id should be correctly set.'); }); diff --git a/packages/store/addon/-private/index.js b/packages/store/addon/-private/index.ts similarity index 100% rename from packages/store/addon/-private/index.js rename to packages/store/addon/-private/index.ts diff --git a/packages/store/addon/-private/system/coerce-id.ts b/packages/store/addon/-private/system/coerce-id.ts index 38e90c95c64..05d5956db6f 100644 --- a/packages/store/addon/-private/system/coerce-id.ts +++ b/packages/store/addon/-private/system/coerce-id.ts @@ -6,9 +6,8 @@ // corresponding record, we will not know if it is a string or a number. type Coercable = string | number | boolean | null | undefined | symbol; -function coerceId(id: number | boolean | symbol): string; -function coerceId(id: null | undefined | ''): null; -function coerceId(id: string | undefined): string | null; +function coerceId(id: null | undefined | string): null; +function coerceId(id: string | number | boolean | symbol): string; function coerceId(id: Coercable): string | null { if (id === null || id === undefined || id === '') { return null; diff --git a/packages/store/addon/-private/system/internal-model-map.ts b/packages/store/addon/-private/system/internal-model-map.ts index 504b1c77866..a1bdf278c7c 100644 --- a/packages/store/addon/-private/system/internal-model-map.ts +++ b/packages/store/addon/-private/system/internal-model-map.ts @@ -24,8 +24,8 @@ export default class InternalModelMap { * @param id {String} * @return {InternalModel} */ - get(id: string): InternalModel | undefined { - return this._idToModel[id]; + get(id: string): InternalModel | null { + return this._idToModel[id] || null; } has(id: string): boolean { @@ -37,10 +37,7 @@ export default class InternalModelMap { } set(id: string, internalModel: InternalModel): void { - assert( - `You cannot index an internalModel by an empty id'`, - typeof id === 'string' && id.length > 0 - ); + assert(`You cannot index an internalModel by an empty id'`, typeof id === 'string' && id.length > 0); assert( `You cannot set an index for an internalModel to something other than an internalModel`, internalModel instanceof InternalModel @@ -57,7 +54,7 @@ export default class InternalModelMap { this._idToModel[id] = internalModel; } - add(internalModel: InternalModel, id?: string): void { + add(internalModel: InternalModel, id: string | null): void { assert( `You cannot re-add an already present InternalModel to the InternalModelMap.`, !this.contains(internalModel) diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 37f39587a31..c8643ac8255 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -25,6 +25,8 @@ import { JsonApiResource, JsonApiValidationError } from '../../ts-interfaces/rec import { Record } from '../../ts-interfaces/record'; import { Dict } from '../../types'; import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; +import { internalModelFactoryFor } from '../store/internal-model-factory'; +import coerceId from '../coerce-id'; // move to TS hacks module that we can delete when this is no longer a necessary recast type ManyArray = InstanceType; @@ -98,10 +100,6 @@ let InternalModelReferenceId = 1; @class InternalModel */ export default class InternalModel { - id: string | null; - store: Store; - modelName: string; - clientId: string | null; __recordData: RecordData | null; _isDestroyed: boolean; isError: boolean; @@ -131,12 +129,12 @@ export default class InternalModel { currentState: any; error: any; - constructor(modelName: string, id: string | null, store, data, clientId) { - this.id = id; - this.store = store; - this.modelName = modelName; - this.clientId = clientId; - + constructor( + public modelName: string, + public id: string | null, + public store: Store, + public clientId?: string | null + ) { this.__recordData = null; // this ensure ordered set can quickly identify this as unique @@ -183,7 +181,7 @@ export default class InternalModel { get _recordData(): RecordData { if (this.__recordData === null) { - let recordData = this.store._createRecordData(this.modelName, this.id, this.clientId, this); + let recordData = this.store._createRecordData(this.modelName, this.id, this.clientId); this._recordData = recordData; return recordData; } @@ -344,7 +342,11 @@ export default class InternalModel { ); if ('id' in properties) { - this.setId(properties.id); + const id = coerceId(properties.id); + + if (id !== null) { + this.setId(id); + } } // convert relationship Records to RecordDatas before passing to RecordData @@ -834,7 +836,7 @@ export default class InternalModel { delete this._retainedManyArrayCache[key]; }); - this.store._removeFromIdMap(this); + internalModelFactoryFor(this.store).remove(this); this._isDestroyed = true; } @@ -997,7 +999,7 @@ export default class InternalModel { } } - notifyHasManyChange(key, record, idx) { + notifyHasManyChange(key: string) { if (this.hasRecord) { let manyArray = this._manyArrayCache[key]; if (manyArray) { @@ -1014,9 +1016,9 @@ export default class InternalModel { } } - notifyBelongsToChange(key, record) { + notifyBelongsToChange(key: string) { if (this.hasRecord) { - this._record.notifyBelongsToChange(key, record); + this._record.notifyBelongsToChange(key, this._record); this.updateRecordArrays(); } } @@ -1251,7 +1253,7 @@ export default class InternalModel { this.store.recordArrayManager.recordDidChange(this); } - setId(id) { + setId(id: string) { assert( "A record's id cannot be changed once it is in the loaded state", this.id === null || this.id === id || this.isNew() @@ -1261,7 +1263,7 @@ export default class InternalModel { this.id = id; if (didChange && id !== null) { - this.store._setRecordId(this, id, this.clientId); + this.store.setRecordId(this.modelName, id, this.clientId as string); } if (didChange && this.hasRecord) { diff --git a/packages/store/addon/-private/system/model/model.js b/packages/store/addon/-private/system/model/model.js index 8281cd81a8e..29fb01f5bae 100644 --- a/packages/store/addon/-private/system/model/model.js +++ b/packages/store/addon/-private/system/model/model.js @@ -18,6 +18,7 @@ import Ember from 'ember'; import InternalModel from './internal-model'; import RootState from './states'; import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; +import coerceId from '../coerce-id'; const { changeProperties } = Ember; @@ -1240,7 +1241,11 @@ Object.defineProperty(Model.prototype, 'data', { const ID_DESCRIPTOR = { configurable: false, set(id) { - this._internalModel.setId(id); + const normalizedId = coerceId(id); + + if (normalizedId !== null) { + this._internalModel.setId(normalizedId); + } }, get() { diff --git a/packages/store/addon/-private/system/record-array-manager.js b/packages/store/addon/-private/system/record-array-manager.js index 10288508e8e..7aa9293149b 100644 --- a/packages/store/addon/-private/system/record-array-manager.js +++ b/packages/store/addon/-private/system/record-array-manager.js @@ -8,6 +8,7 @@ import { run as emberRunloop } from '@ember/runloop'; import { assert } from '@ember/debug'; import cloneNull from './clone-null'; import { RecordArray, AdapterPopulatedRecordArray } from './record-arrays'; +import { internalModelFactoryFor } from './store/internal-model-factory'; const emberRun = emberRunloop.backburner; @@ -99,7 +100,7 @@ export default class RecordArrayManager { let pending = this._pending[modelName]; let hasPendingChanges = Array.isArray(pending); let hasNoPotentialDeletions = !hasPendingChanges || pending.length === 0; - let map = this.store._internalModelsFor(modelName); + let map = internalModelFactoryFor(this.store).modelMapFor(modelName); let hasNoInsertionsOrRemovals = get(map, 'length') === get(array, 'length'); /* @@ -171,7 +172,7 @@ export default class RecordArrayManager { } _visibleInternalModelsByType(modelName) { - let all = this.store._internalModelsFor(modelName)._models; + let all = internalModelFactoryFor(this.store).modelMapFor(modelName)._models; let visible = []; for (let i = 0; i < all.length; i++) { let model = all[i]; diff --git a/packages/store/addon/-private/system/store.ts b/packages/store/addon/-private/system/store.ts index 45a5d81bb57..b5c81fb2c12 100644 --- a/packages/store/addon/-private/system/store.ts +++ b/packages/store/addon/-private/system/store.ts @@ -19,7 +19,6 @@ import { assert, warn, deprecate, inspect } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import Model from './model/model'; import normalizeModelName from './normalize-model-name'; -import IdentityMap from './identity-map'; import RecordDataStoreWrapper from './store/record-data-store-wrapper'; import { promiseArray, promiseObject } from './promise-proxies'; @@ -41,6 +40,10 @@ import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-featur import { Record } from '../ts-interfaces/record'; import promiseRecord from '../utils/promise-record'; +import { internalModelFactoryFor } from './store/internal-model-factory'; +import RecordData from '../ts-interfaces/record-data'; +import { RecordReference } from './references'; +import { JsonApiResourceIdentity } from '../ts-interfaces/record-data-json-api'; const badIdFormatAssertion = '`id` passed to `findRecord()` has to be non-empty string or number'; const emberRun = emberRunLoop.backburner; @@ -145,9 +148,6 @@ const Store = Service.extend({ this._backburner = edBackburner; // internal bookkeeping; not observable this.recordArrayManager = new RecordArrayManager({ store: this }); - this._identityMap = new IdentityMap(); - // To keep track of clientIds for newly created records - this._newlyCreated = new IdentityMap(); this._pendingSave = []; this._modelFactoryCache = Object.create(null); this._relationshipsDefCache = Object.create(null); @@ -347,7 +347,9 @@ const Store = Service.extend({ // Coerce ID to a string properties.id = coerceId(properties.id); - let internalModel = this._buildInternalModel(normalizedModelName, properties.id); + const factory = internalModelFactoryFor(this); + const internalModel = factory.build(normalizedModelName, properties.id); + internalModel.loadedData(); // TODO this exists just to proxy `isNew` to RecordData which is weird internalModel.didCreateRecord(); @@ -703,12 +705,12 @@ const Store = Service.extend({ ); assert(badIdFormatAssertion, (typeof id === 'string' && id.length > 0) || (typeof id === 'number' && !isNaN(id))); - let normalizedModelName = normalizeModelName(modelName); - - let internalModel = this._internalModelForId(normalizedModelName, id); + const normalizedModelName = normalizeModelName(modelName); + const normalizedId = coerceId(id); + const internalModel = internalModelFactoryFor(this).lookup(normalizedModelName, normalizedId, null); options = options || {}; - if (!this.hasRecordForId(normalizedModelName, id)) { + if (!this.hasRecordForId(normalizedModelName, normalizedId)) { return this._findByInternalModel(internalModel, options); } @@ -1067,13 +1069,14 @@ const Store = Service.extend({ @since 2.5.0 @return {RecordReference} */ - getReference(modelName, id) { + getReference(modelName: string, id: string | number): RecordReference { if (DEBUG) { assertDestroyingStore(this, 'getReference'); } - let normalizedModelName = normalizeModelName(modelName); + const normalizedModelName = normalizeModelName(modelName); + const normalizedId = coerceId(id); - return this._internalModelForId(normalizedModelName, id).recordReference; + return internalModelFactoryFor(this).lookup(normalizedModelName, normalizedId, null).recordReference; }, /** @@ -1099,7 +1102,7 @@ const Store = Service.extend({ @param {String|Integer} id @return {Model|null} record */ - peekRecord(modelName, id) { + peekRecord(modelName: string, id: string | number): Record | null { if (DEBUG) { assertDestroyingStore(this, 'peekRecord'); } @@ -1113,9 +1116,12 @@ const Store = Service.extend({ typeof modelName === 'string' ); let normalizedModelName = normalizeModelName(modelName); + const normalizedId = coerceId(id); - if (this.hasRecordForId(normalizedModelName, id)) { - return this._internalModelForId(normalizedModelName, id).getRecord(); + if (this.hasRecordForId(normalizedModelName, normalizedId)) { + return internalModelFactoryFor(this) + .lookup(normalizedModelName, normalizedId, null) + .getRecord(); } else { return null; } @@ -1167,7 +1173,7 @@ const Store = Service.extend({ @param {(String|Integer)} id @return {Boolean} */ - hasRecordForId(modelName, id) { + hasRecordForId(modelName: string, id: string | number): boolean { if (DEBUG) { assertDestroyingStore(this, 'hasRecordForId'); } @@ -1177,10 +1183,9 @@ const Store = Service.extend({ typeof modelName === 'string' ); - let normalizedModelName = normalizeModelName(modelName); - - let trueId = coerceId(id); - let internalModel = this._internalModelsFor(normalizedModelName).get(trueId); + const normalizedModelName = normalizeModelName(modelName); + const trueId = coerceId(id); + const internalModel = internalModelFactoryFor(this).peekId(normalizedModelName, trueId, null); return !!internalModel && internalModel.isLoaded(); }, @@ -1195,7 +1200,7 @@ const Store = Service.extend({ @param {(String|Integer)} id @return {Model} record */ - recordForId(modelName, id) { + recordForId(modelName: string, id: string | number): Record { if (DEBUG) { assertDestroyingStore(this, 'recordForId'); } @@ -1205,42 +1210,9 @@ const Store = Service.extend({ typeof modelName === 'string' ); - return this._internalModelForId(modelName, id).getRecord(); - }, - - // directly get an internal model from ID map if it is there, without doing any - // processing - _getInternalModelForId(modelName, id, clientId) { - let internalModel; - if (clientId) { - internalModel = this._newlyCreatedModelsFor(modelName).get(clientId); - } - - if (!internalModel) { - internalModel = this._internalModelsFor(modelName).get(id); - } - return internalModel; - }, - - _internalModelForId(modelName, id, clientId) { - let trueId = coerceId(id); - let internalModel = this._getInternalModelForId(modelName, trueId, clientId); - - if (internalModel) { - // unloadRecord is async, if one attempts to unload + then sync push, - // we must ensure the unload is canceled before continuing - // The createRecord path will take _existingInternalModelForId() - // which will call `destroySync` instead for this unload + then - // sync createRecord scenario. Once we have true client-side - // delete signaling, we should never call destroySync - if (internalModel.hasScheduledDestroy()) { - internalModel.cancelDestroy(); - } - - return internalModel; - } - - return this._buildInternalModel(modelName, trueId, null, clientId); + return internalModelFactoryFor(this) + .lookup(modelName, coerceId(id), null) + .getRecord(); }, /** @@ -2013,7 +1985,7 @@ const Store = Service.extend({ @method unloadAll @param {String} modelName */ - unloadAll(modelName) { + unloadAll(modelName?: string) { if (DEBUG) { assertDestroyedStoreOnly(this, 'unloadAll'); } @@ -2022,11 +1994,13 @@ const Store = Service.extend({ !modelName || typeof modelName === 'string' ); - if (arguments.length === 0) { - this._identityMap.clear(); + const factory = internalModelFactoryFor(this); + + if (modelName === undefined) { + factory.clear(); } else { let normalizedModelName = normalizeModelName(modelName); - this._internalModelsFor(normalizedModelName).clear(); + factory.clear(normalizedModelName); } }, @@ -2195,68 +2169,11 @@ const Store = Service.extend({ @param {string} newId @param {string} clientId */ - setRecordId(modelName, newId, clientId) { - let trueId = coerceId(newId); - let internalModel = this._getInternalModelForId(modelName, trueId, clientId); - this._setRecordId(internalModel, newId, clientId); - }, - - _setRecordId(internalModel, id, clientId) { + setRecordId(modelName: string, newId: string, clientId: string) { if (DEBUG) { assertDestroyingStore(this, 'setRecordId'); } - let oldId = internalModel.id; - let modelName = internalModel.modelName; - - // ID absolutely can't be missing if the oldID is empty (missing Id in response for a new record) - assert( - `'${modelName}' was saved to the server, but the response does not have an id and your record does not either.`, - !(id === null && oldId === null) - ); - - // ID absolutely can't be different than oldID if oldID is not null - assert( - `'${modelName}:${oldId}' was saved to the server, but the response returned the new id '${id}'. The store cannot assign a new id to a record that already has an id.`, - !(oldId !== null && id !== oldId) - ); - - // ID can be null if oldID is not null (altered ID in response for a record) - // however, this is more than likely a developer error. - if (oldId !== null && id === null) { - warn( - `Your ${modelName} record was saved to the server, but the response does not have an id.`, - !(oldId !== null && id === null) - ); - return; - } - - let existingInternalModel = this._existingInternalModelForId(modelName, id); - - assert( - `'${modelName}' was saved to the server, but the response returned the new id '${id}', which has already been used with another record.'`, - isNone(existingInternalModel) || existingInternalModel === internalModel - ); - - this._internalModelsFor(internalModel.modelName).set(id, internalModel); - this._newlyCreatedModelsFor(internalModel.modelName).remove(internalModel, clientId); - - internalModel.setId(id); - }, - - /** - Returns a map of IDs to client IDs for a given modelName. - - @method _internalModelsFor - @private - @param {String} modelName - @return {Object} recordMap - */ - _internalModelsFor(modelName) { - return this._identityMap.retrieve(modelName); - }, - - _newlyCreatedModelsFor(modelName) { - return this._newlyCreated.retrieve(modelName); + internalModelFactoryFor(this).setRecordId(modelName, newId, clientId); }, // ................ @@ -2271,8 +2188,9 @@ const Store = Service.extend({ @param {Object} data */ _load(data) { - let modelName = normalizeModelName(data.type); - let internalModel = this._internalModelForId(modelName, data.id); + const modelName = normalizeModelName(data.type); + const id = coerceId(data.id); + const internalModel = internalModelFactoryFor(this).lookup(modelName, id); let isUpdate = internalModel.currentState.isEmpty === false; @@ -2745,27 +2663,29 @@ const Store = Service.extend({ return relationships; }, - _internalModelForResource(resource) { - let internalModel; - if (resource.clientId) { - internalModel = this._newlyCreatedModelsFor(resource.type).get(resource.clientId); - } - if (!internalModel) { - internalModel = this._internalModelForId(resource.type, resource.id); - } - return internalModel; + _internalModelForResource(resource: JsonApiResourceIdentity): InternalModel { + return internalModelFactoryFor(this).getByResource(resource); + }, + + /** + * TODO Only needed temporarily for test support + * + * @internal + */ + _internalModelForId(modelName: string, id: string | null, lid: string | null): InternalModel { + return internalModelFactoryFor(this).lookup(modelName, id, lid); }, - _createRecordData(modelName, id, clientId, internalModel) { + _createRecordData(modelName, id, clientId): RecordData { return this.createRecordDataFor(modelName, id, clientId, this.storeWrapper); }, - createRecordDataFor(modelName, id, clientId, storeWrapper) { + createRecordDataFor(modelName, id, clientId, storeWrapper): RecordData { return new RecordDataDefault(modelName, id, clientId, storeWrapper); }, - recordDataFor(modelName, id, clientId) { - let internalModel = this._internalModelForId(modelName, id, clientId); + recordDataFor(modelName: string, id: string | null, clientId?: string | null): RecordData { + let internalModel = internalModelFactoryFor(this).lookup(modelName, id, clientId); return recordDataFor(internalModel); }, @@ -2808,60 +2728,6 @@ const Store = Service.extend({ newClientId() { return globalClientIdCounter++; }, - /** - Build a brand new record for a given type, ID, and - initial data. - - @method _buildInternalModel - @private - @param {String} modelName - @param {String} id - @param {Object} data - @return {InternalModel} internal model - */ - _buildInternalModel(modelName, id, data, clientId) { - assert( - `You can no longer pass a modelClass as the first argument to store._buildInternalModel. Pass modelName instead.`, - typeof modelName === 'string' - ); - - let existingInternalModel = this._existingInternalModelForId(modelName, id); - - assert( - `The id ${id} has already been used with another record for modelClass '${modelName}'.`, - !existingInternalModel - ); - - if (id === null && !clientId) { - clientId = this.newClientId(); - } - // lookupFactory should really return an object that creates - // instances with the injections applied - let internalModel = new InternalModel(modelName, id, this, data, clientId); - if (clientId) { - this._newlyCreatedModelsFor(modelName).add(internalModel, clientId); - } - - this._internalModelsFor(modelName).add(internalModel, id); - - return internalModel; - }, - - _existingInternalModelForId(modelName, id) { - let internalModel = this._internalModelsFor(modelName).get(id); - - if (internalModel && internalModel.hasScheduledDestroy()) { - // unloadRecord is async, if one attempts to unload + then sync create, - // we must ensure the unload is complete before starting the create - // The push path will take _internalModelForId() - // which will call `cancelDestroy` instead for this unload + then - // sync push scenario. Once we have true client-side - // delete signaling, we should never call destroySync - internalModel.destroySync(); - internalModel = null; - } - return internalModel; - }, //Called by the state machine to notify the store that the record is ready to be interacted with recordWasLoaded(record) { @@ -2876,19 +2742,12 @@ const Store = Service.extend({ // ............... /** - When a record is destroyed, this un-indexes it and - removes it from any record arrays so it can be GCed. - - @method _removeFromIdMap - @private - @param {InternalModel} internalModel - */ - _removeFromIdMap(internalModel) { - let recordMap = this._internalModelsFor(internalModel.modelName); - let id = internalModel.id; - - recordMap.remove(internalModel, id); - //TODO IGOR DAVID remove from client id map + * TODO remove test usage + * + * @internal + */ + _internalModelsFor(modelName: string) { + return internalModelFactoryFor(this).modelMapFor(modelName); }, // ...................... diff --git a/packages/store/addon/-private/system/store/internal-model-factory.ts b/packages/store/addon/-private/system/store/internal-model-factory.ts new file mode 100644 index 00000000000..0f8363e46eb --- /dev/null +++ b/packages/store/addon/-private/system/store/internal-model-factory.ts @@ -0,0 +1,228 @@ +import coerceId from '../coerce-id'; +import { assert, warn } from '@ember/debug'; +import InternalModel from '../model/internal-model'; +import Store from '../store'; +import IdentityMap from '../identity-map'; +import InternalModelMap from '../internal-model-map'; +import { isNone } from '@ember/utils'; +import { JsonApiResourceIdentity } from '../../ts-interfaces/record-data-json-api'; + +const FactoryCache = new WeakMap(); + +let globalClientIdCounter = 1; + +export function internalModelFactoryFor(store: Store): InternalModelFactory { + let factory = FactoryCache.get(store); + + if (factory === undefined) { + factory = new InternalModelFactory(store); + FactoryCache.set(store, factory); + } + + return factory; +} + +/** + * The InternalModelFactory handles the lifecyle of + * instantiating, caching, and destroying InternalModel + * instances. + * + * @internal + */ +export default class InternalModelFactory { + private _identityMap: IdentityMap; + private _newlyCreated: IdentityMap; + + constructor(public store: Store) { + this._identityMap = new IdentityMap(); + // To keep track of clientIds for newly created records + this._newlyCreated = new IdentityMap(); + } + + /** + * Retrieve the InternalModel for a given { type, id, lid }. + * + * If an InternalModel does not exist, it instantiates one. + * + * If an InternalModel does exist bus has a scheduled destroy, + * the scheduled destroy will be cancelled. + * + * @internal + */ + lookup(modelName: string, id: string | null, clientId?: string | null): InternalModel { + let trueId = id === null ? null : coerceId(id); + let internalModel = this.peekId(modelName, trueId, clientId); + + if (internalModel) { + // unloadRecord is async, if one attempts to unload + then sync push, + // we must ensure the unload is canceled before continuing + // The createRecord path will take _existingInternalModelForId() + // which will call `destroySync` instead for this unload + then + // sync createRecord scenario. Once we have true client-side + // delete signaling, we should never call destroySync + if (internalModel.hasScheduledDestroy()) { + internalModel.cancelDestroy(); + } + + return internalModel; + } + + return this.build(modelName, trueId, null, clientId); + } + + /** + * Peek the InternalModel for a given { type, id, lid }. + * + * If an InternalModel does not exist, return `null`. + * + * @internal + */ + peekId(modelName: string, id: string | null, clientId?: string | null): InternalModel | null { + let internalModel: InternalModel | null = null; + + if (clientId) { + internalModel = this._newlyCreatedModelsFor(modelName).get(clientId); + } + + if (!internalModel && id) { + internalModel = this.modelMapFor(modelName).get(id); + } + + return internalModel; + } + + getByResource(resource: JsonApiResourceIdentity): InternalModel { + let internalModel: InternalModel | null = null; + + if (resource.clientId) { + internalModel = this._newlyCreatedModelsFor(resource.type).get(resource.clientId); + } + + if (internalModel === null) { + internalModel = this.lookup(resource.type, resource.id, resource.clientId); + } + + return internalModel; + } + + setRecordId(type: string, id: string, lid: string) { + const internalModel = this.peekId(type, id, lid); + + if (internalModel === null) { + throw new Error(`Cannot set the id ${id} on the record ${type}:${lid} as there is no such record in the cache.`); + } + + let oldId = internalModel.id; + let modelName = internalModel.modelName; + + // ID absolutely can't be missing if the oldID is empty (missing Id in response for a new record) + assert( + `'${modelName}' was saved to the server, but the response does not have an id and your record does not either.`, + !(id === null && oldId === null) + ); + + // ID absolutely can't be different than oldID if oldID is not null + assert( + `'${modelName}:${oldId}' was saved to the server, but the response returned the new id '${id}'. The store cannot assign a new id to a record that already has an id.`, + !(oldId !== null && id !== oldId) + ); + + // ID can be null if oldID is not null (altered ID in response for a record) + // however, this is more than likely a developer error. + if (oldId !== null && id === null) { + warn( + `Your ${modelName} record was saved to the server, but the response does not have an id.`, + !(oldId !== null && id === null) + ); + return; + } + + let existingInternalModel = this.peekIdOnly(modelName, id); + + assert( + `'${modelName}' was saved to the server, but the response returned the new id '${id}', which has already been used with another record.'`, + isNone(existingInternalModel) || existingInternalModel === internalModel + ); + + this.modelMapFor(internalModel.modelName).set(id, internalModel); + this._newlyCreatedModelsFor(internalModel.modelName).remove(internalModel, lid); + + internalModel.setId(id); + } + + peekIdOnly(modelName: string, id: string): InternalModel | null { + let internalModel: InternalModel | null = this.modelMapFor(modelName).get(id); + + if (internalModel && internalModel.hasScheduledDestroy()) { + // unloadRecord is async, if one attempts to unload + then sync create, + // we must ensure the unload is complete before starting the create + // The push path will take this.lookup() + // which will call `cancelDestroy` instead for this unload + then + // sync push scenario. Once we have true client-side + // delete signaling, we should never call destroySync + internalModel.destroySync(); + internalModel = null; + } + return internalModel; + } + + build(modelName: string, id: string | null, data?: any, clientId?: string | null) { + if (id) { + let existingInternalModel = this.peekIdOnly(modelName, id); + + assert( + `The id ${id} has already been used with another record for modelClass '${modelName}'.`, + !existingInternalModel + ); + } + + if (id === null && !clientId) { + clientId = `client-id:${this.newClientId()}`; + } + + // lookupFactory should really return an object that creates + // instances with the injections applied + let internalModel = new InternalModel(modelName, id, this.store, clientId); + if (clientId) { + this._newlyCreatedModelsFor(modelName).add(internalModel, clientId); + } + + this.modelMapFor(modelName).add(internalModel, id); + + return internalModel; + } + + newClientId() { + return globalClientIdCounter++; + } + + remove(internalModel: InternalModel): void { + let recordMap = this.modelMapFor(internalModel.modelName); + let id = internalModel.id; + let clientId = internalModel.clientId; + + if (id) { + recordMap.remove(internalModel, id); + } + + if (clientId) { + this._newlyCreatedModelsFor(internalModel.modelName).remove(internalModel, clientId); + } + } + + modelMapFor(modelName: string): InternalModelMap { + return this._identityMap.retrieve(modelName); + } + + _newlyCreatedModelsFor(modelName: string): InternalModelMap { + return this._newlyCreated.retrieve(modelName); + } + + clear(modelName?: string) { + if (modelName === undefined) { + this._identityMap.clear(); + } else { + this.modelMapFor(modelName).clear(); + } + } +} diff --git a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts index 4f9b50a43ca..73abdb28e26 100644 --- a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts @@ -4,6 +4,7 @@ import { AttributesSchema, RelationshipsSchema } from '../../ts-interfaces/recor import { BRAND_SYMBOL } from '../../ts-interfaces/utils/brand'; import { upgradeForInternal } from '../ts-upgrade-map'; import RecordData from '../../ts-interfaces/record-data'; +import { internalModelFactoryFor } from './internal-model-factory'; type StringOrNullOrUndefined = string | null | undefined; @@ -53,8 +54,11 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { } notifyErrorsChange(modelName: string, id: string | null, clientId: string | null) { - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); - internalModel.notifyErrorsChange(); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); + + if (internalModel) { + internalModel.notifyErrorsChange(); + } } _flushPendingManyArrayUpdates(): void { @@ -65,15 +69,18 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { let pending = this._pendingManyArrayUpdates; this._pendingManyArrayUpdates = []; this._willUpdateManyArrays = false; - let store = this._store; + const factory = internalModelFactoryFor(this._store); for (let i = 0; i < pending.length; i += 4) { - let modelName = pending[i]; - let id = pending[i + 1]; + let modelName = pending[i] as string; + let id = pending[i + 1] || null; let clientId = pending[i + 2]; - let key = pending[i + 3]; - let internalModel = store._getInternalModelForId(modelName, id, clientId); - internalModel.notifyHasManyChange(key); + let key = pending[i + 3] as string; + let internalModel = factory.peekId(modelName, id, clientId); + + if (internalModel) { + internalModel.notifyHasManyChange(key); + } } } @@ -105,8 +112,11 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { if (!hasValidId(id, clientId)) { throw new Error(MISSING_ID_ARG_ERROR_MESSAGE); } - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); - internalModel.notifyPropertyChange(key); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); + + if (internalModel) { + internalModel.notifyPropertyChange(key); + } } notifyHasManyChange(modelName: string, id: string | null, clientId: string, key: string): void; @@ -124,12 +134,16 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { if (!hasValidId(id, clientId)) { throw new Error(MISSING_ID_ARG_ERROR_MESSAGE); } - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); - internalModel.notifyBelongsToChange(key); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); + + if (internalModel) { + internalModel.notifyBelongsToChange(key); + } } notifyStateChange(modelName: string, id: string | null, clientId: string | null, key?: string): void { - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); + if (internalModel) { internalModel.notifyStateChange(key); } @@ -156,7 +170,7 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { throw new Error(MISSING_ID_ARG_ERROR_MESSAGE); } - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); if (!internalModel) { return false; } @@ -170,7 +184,7 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { throw new Error(MISSING_ID_ARG_ERROR_MESSAGE); } - let internalModel = this._store._getInternalModelForId(modelName, id, clientId); + let internalModel = internalModelFactoryFor(this._store).peekId(modelName, id, clientId); if (internalModel) { internalModel.destroyFromRecordData(); } diff --git a/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts b/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts index b715ef26e6c..6a5437b02f7 100644 --- a/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts +++ b/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts @@ -15,11 +15,21 @@ export interface JsonApiResource { }; meta?: any; } -export interface JsonApiResourceIdentity { - id?: string | null; + +export interface ExistingResourceIdentifierObject { type: string; - clientId?: string; + id: string; + clientId?: string | null; } + +export interface NewResourceIdentifierObject { + type: string; + id: string | null; + clientId: string; +} + +export type JsonApiResourceIdentity = ExistingResourceIdentifierObject | NewResourceIdentifierObject; + export interface JsonApiBelongsToRelationship { data?: JsonApiResourceIdentity; meta?: any;