diff --git a/packages/-ember-data/node-tests/fixtures/expected.js b/packages/-ember-data/node-tests/fixtures/expected.js index 56d0e6d2bde..7013ab27547 100644 --- a/packages/-ember-data/node-tests/fixtures/expected.js +++ b/packages/-ember-data/node-tests/fixtures/expected.js @@ -55,7 +55,6 @@ module.exports = { '(private) @ember-data/serializer JSONSerializer#normalizeUsingDeclaredMapping', '(private) @ember-data/serializer JSONSerializer#transformFor', '(private) @ember-data/serializer RESTSerializer#_normalizeArray', - '(private) @ember-data/store AdapterPopulatedRecordArray#_setInternalModels', '(private) @ember-data/store Errors#_add', '(private) @ember-data/store Errors#_clear', '(private) @ember-data/store Errors#_findOrCreateMessages', @@ -69,12 +68,13 @@ module.exports = { '(private) @ember-data/store ManyArray#isPolymorphic', '(private) @ember-data/store ManyArray#promise', '(private) @ember-data/store ManyArray#relationship', - '(private) @ember-data/store RecordArray#_pushInternalModels', - '(private) @ember-data/store RecordArray#_unregisterFromManager', '(private) @ember-data/store RecordArray#content', '(private) @ember-data/store RecordArray#objectAtContent', - '(private) @ember-data/store RecordArray#removeInternalModel', '(private) @ember-data/store RecordArray#store', + '(private) @ember-data/store RecordArrayManager#_associateWithRecordArray', + '(private) @ember-data/store RecordArray#_pushInternalModels', + '(private) @ember-data/store RecordArray#_removeInternalModels', + '(private) @ember-data/store RecordArray#_unregisterFromManager', '(private) @ember-data/store SnapshotRecordArray#_recordArray', '(private) @ember-data/store SnapshotRecordArray#_snapshots', '(private) @ember-data/store Store#_didUpdateAll', @@ -323,6 +323,7 @@ module.exports = { '(public) @ember-data/store RecordArray#update', '(public) @ember-data/store RecordArrayManager#createAdapterPopulatedRecordArray', '(public) @ember-data/store RecordArrayManager#createRecordArray', + '(public) @ember-data/store RecordArrayManager#getRecordArraysForIdentifier', '(public) @ember-data/store RecordArrayManager#liveRecordArrayFor', '(public) @ember-data/store RecordArrayManager#unregisterRecordArray', '(public) @ember-data/store RecordReference#id', diff --git a/packages/-ember-data/tests/integration/record-array-manager-test.js b/packages/-ember-data/tests/integration/record-array-manager-test.js index 338f98b8d97..6a14f3c06a1 100644 --- a/packages/-ember-data/tests/integration/record-array-manager-test.js +++ b/packages/-ember-data/tests/integration/record-array-manager-test.js @@ -7,7 +7,9 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import RESTAdapter from '@ember-data/adapter/rest'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; +import { recordIdentifierFor } from '@ember-data/store'; let store, manager; @@ -100,24 +102,48 @@ module('integration/record_array_manager', function(hooks) { assert.equal(allSummary.called.length, 0, 'initial: no calls to all.willDestroy'); assert.equal(adapterPopulatedSummary.called.length, 0, 'initial: no calls to adapterPopulated.willDestroy'); - assert.equal( - internalPersonModel._recordArrays.size, - 1, - 'initial: expected the person to be a member of 1 recordArrays' - ); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + assert.equal( + manager.getRecordArraysForIdentifier(internalPersonModel.identifier).size, + 1, + 'initial: expected the person to be a member of 1 recordArrays' + ); + } else { + assert.equal( + internalPersonModel._recordArrays.size, + 1, + 'initial: expected the person to be a member of 1 recordArrays' + ); + } assert.equal('person' in manager._liveRecordArrays, true, 'initial: we have a live array for person'); all.destroy(); await settled(); - assert.equal(internalPersonModel._recordArrays.size, 0, 'expected the person to be a member of 1 recordArrays'); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + assert.equal( + manager.getRecordArraysForIdentifier(internalPersonModel.identifier).size, + 0, + 'expected the person to be a member of no recordArrays' + ); + } else { + assert.equal(internalPersonModel._recordArrays.size, 0, 'expected the person to be a member of no recordArrays'); + } assert.equal(allSummary.called.length, 1, 'all.willDestroy called once'); assert.equal('person' in manager._liveRecordArrays, false, 'no longer have a live array for person'); manager.destroy(); await settled(); - assert.equal(internalPersonModel._recordArrays.size, 0, 'expected the person to be a member of no recordArrays'); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + assert.equal( + manager.getRecordArraysForIdentifier(internalPersonModel.identifier).size, + 0, + 'expected the person to be a member of no recordArrays' + ); + } else { + assert.equal(internalPersonModel._recordArrays.size, 0, 'expected the person to be a member of no recordArrays'); + } assert.equal(allSummary.called.length, 1, 'all.willDestroy still only called once'); assert.equal(adapterPopulatedSummary.called.length, 1, 'adapterPopulated.willDestroy called once'); }); @@ -243,23 +269,42 @@ module('integration/record_array_manager', function(hooks) { }); test('createRecordArray with optional content', function(assert) { - let record = {}; - let internalModel = { - _recordArrays: new OrderedSet(), - getRecord() { - return record; - }, - }; - let content = A([internalModel]); - let recordArray = manager.createRecordArray('foo', content); + let content; + let record; + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + record = store.push({ + data: { + type: 'car', + id: '1', + attributes: { + make: 'BMC', + model: 'Mini Cooper', + }, + }, + }); + content = A([recordIdentifierFor(record)]); + } else { + let internalModel = { + _recordArrays: new OrderedSet(), + getRecord() { + return record; + }, + }; - assert.equal(recordArray.modelName, 'foo'); - assert.equal(recordArray.isLoaded, true); - assert.equal(recordArray.manager, manager); - assert.equal(recordArray.get('content'), content); - assert.deepEqual(recordArray.toArray(), [record]); + content = A([internalModel]); + } + + let recordArray = manager.createRecordArray('foo', content); - assert.deepEqual(internalModel._recordArrays.toArray(), [recordArray]); + assert.equal(recordArray.modelName, 'foo', 'has modelName'); + assert.equal(recordArray.isLoaded, true, 'isLoaded is true'); + assert.equal(recordArray.manager, manager, 'recordArray has manager'); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + assert.deepEqual(recordArray.get('content'), [recordIdentifierFor(record)], 'recordArray has content'); + } else { + assert.equal(recordArray.get('content'), content); + } + assert.deepEqual(recordArray.toArray(), [record], 'toArray works'); }); test('liveRecordArrayFor always return the same array for a given type', function(assert) { diff --git a/packages/-ember-data/tests/integration/record-arrays/adapter-populated-record-array-test.js b/packages/-ember-data/tests/integration/record-arrays/adapter-populated-record-array-test.js index 2f34f326fc7..c73d469c364 100644 --- a/packages/-ember-data/tests/integration/record-arrays/adapter-populated-record-array-test.js +++ b/packages/-ember-data/tests/integration/record-arrays/adapter-populated-record-array-test.js @@ -1,4 +1,3 @@ -import { run } from '@ember/runloop'; import { settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; @@ -8,8 +7,10 @@ import { setupTest } from 'ember-qunit'; import Adapter from '@ember-data/adapter'; import JSONAPIAdapter from '@ember-data/adapter/json-api'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; import Model, { attr } from '@ember-data/model'; import JSONAPISerializer from '@ember-data/serializer/json-api'; +import { recordIdentifierFor } from '@ember-data/store'; const Person = Model.extend({ name: attr('string'), @@ -27,7 +28,7 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula this.owner.register('serializer:application', JSONAPISerializer.extend()); }); - test('when a record is deleted in an adapter populated record array, it should be removed', function(assert) { + test('when a record is deleted in an adapter populated record array, it should be removed', async function(assert) { const ApplicationAdapter = Adapter.extend({ deleteRecord() { return Promise.resolve(); @@ -65,18 +66,30 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula ], }; - run(() => { - recordArray._setInternalModels(store._push(payload), payload); - }); + let results = store.push(payload); + + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + recordArray._setIdentifiers( + results.map(r => recordIdentifierFor(r)), + payload + ); + } else { + recordArray._setInternalModels( + results.map(r => r._internalModel), + payload + ); + } assert.equal(recordArray.get('length'), 3, 'expected recordArray to contain exactly 3 records'); - run(() => recordArray.get('firstObject').destroyRecord()); + recordArray.get('firstObject').destroyRecord(); + + await settled(); assert.equal(recordArray.get('length'), 2, 'expected recordArray to contain exactly 2 records'); }); - test('stores the metadata off the payload', function(assert) { + test('stores the metadata off the payload', async function(assert) { let store = this.owner.lookup('service:store'); let recordArray = store.recordArrayManager.createAdapterPopulatedRecordArray('person', null); @@ -109,14 +122,22 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula }, }; - run(() => { - recordArray._setInternalModels(store._push(payload), payload); - }); - + let results = store.push(payload); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + recordArray._setIdentifiers( + results.map(r => recordIdentifierFor(r)), + payload + ); + } else { + recordArray._setInternalModels( + results.map(r => r._internalModel), + payload + ); + } assert.equal(recordArray.get('meta.foo'), 'bar', 'expected meta.foo to be bar from payload'); }); - test('stores the links off the payload', function(assert) { + test('stores the links off the payload', async function(assert) { let store = this.owner.lookup('service:store'); let recordArray = store.recordArrayManager.createAdapterPopulatedRecordArray('person', null); @@ -149,9 +170,18 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula }, }; - run(() => { - recordArray._setInternalModels(store._push(payload), payload); - }); + let results = store.push(payload); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + recordArray._setIdentifiers( + results.map(r => recordIdentifierFor(r)), + payload + ); + } else { + recordArray._setInternalModels( + results.map(r => r._internalModel), + payload + ); + } assert.equal( recordArray.get('links.first'), @@ -175,7 +205,7 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula ); }); - test('pass record array to adapter.query regardless of arity', function(assert) { + test('pass record array to adapter.query regardless of arity', async function(assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); @@ -188,20 +218,20 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula adapter.query = function(store, type, query) { // Due to #6232, we now expect 5 arguments regardless of arity - assert.equal(arguments.length, 5); + assert.equal(arguments.length, 5, 'expect 5 arguments in query'); return payload; }; - return store.query('person', {}).then(recordArray => { - adapter.query = function(store, type, query, _recordArray) { - assert.equal(arguments.length, 5); - return payload; - }; - return store.query('person', {}); - }); + await store.query('person', {}); + + adapter.query = function(store, type, query, recordArray) { + assert.equal(arguments.length, 5); + return payload; + }; + store.query('person', {}); }); - test('pass record array to adapter.query regardless of arity', function(assert) { + test('pass record array to adapter.query regardless of arity', async function(assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); @@ -232,25 +262,25 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula return payload; }; - return store.query('person', actualQuery).then(recordArray => { - adapter.query = function(store, type, query, _recordArray) { - assert.equal(arguments.length, 5); - return payload; - }; + await store.query('person', actualQuery); - store.recordArrayManager.createStore = function(modelName, query) { - assert.equal(arguments.length === 2); + adapter.query = function(store, type, query, _recordArray) { + assert.equal(arguments.length, 5); + return payload; + }; - assert.equal(modelName, 'person'); - assert.equal(query, actualQuery); - return superCreateAdapterPopulatedRecordArray.apply(this, arguments); - }; + store.recordArrayManager.createStore = function(modelName, query) { + assert.equal(arguments.length === 2); - return store.query('person', actualQuery); - }); + assert.equal(modelName, 'person'); + assert.equal(query, actualQuery); + return superCreateAdapterPopulatedRecordArray.apply(this, arguments); + }; + + store.query('person', actualQuery); }); - test('loadRecord re-syncs internalModels recordArrays', function(assert) { + test('loadRecord re-syncs internalModels recordArrays', async function(assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); @@ -265,35 +295,35 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula return payload; }; - return store.query('person', {}).then(recordArray => { - return recordArray - .update() - .then(recordArray => { - assert.deepEqual( - recordArray.getEach('name'), - ['Scumbag Dale', 'Scumbag Katz'], - 'expected query to contain specific records' - ); - - payload = { - data: [ - { id: '1', type: 'person', attributes: { name: 'Scumbag Dale' } }, - { id: '3', type: 'person', attributes: { name: 'Scumbag Penner' } }, - ], - }; - - return recordArray.update(); - }) - .then(recordArray => { - assert.deepEqual(recordArray.getEach('name'), ['Scumbag Dale', 'Scumbag Penner']); - }); - }); + let recordArray = await store.query('person', {}); + + recordArray = await recordArray.update(); + assert.deepEqual( + recordArray.getEach('name'), + ['Scumbag Dale', 'Scumbag Katz'], + 'expected query to contain specific records' + ); + + payload = { + data: [ + { id: '1', type: 'person', attributes: { name: 'Scumbag Dale' } }, + { id: '3', type: 'person', attributes: { name: 'Scumbag Penner' } }, + ], + }; + + recordArray = await recordArray.update(); + + assert.deepEqual( + recordArray.getEach('name'), + ['Scumbag Dale', 'Scumbag Penner'], + 'expected query to still contain specific records' + ); }); - test('when an adapter populated record gets updated the array contents are also updated', function(assert) { + test('when an adapter populated record gets updated the array contents are also updated', async function(assert) { assert.expect(8); - let queryPromise, queryArr, findPromise, findArray; + let queryArr, findArray; let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); let array = [{ id: '1', type: 'person', attributes: { name: 'Scumbag Dale' } }]; @@ -309,42 +339,27 @@ module('integration/record-arrays/adapter_populated_record_array - AdapterPopula return { data: array.slice(0) }; }; - run(() => { - queryPromise = store.query('person', { slice: 1 }); - findPromise = store.findAll('person'); - - // initialize adapter populated record array and assert initial state - queryPromise.then(_queryArr => { - queryArr = _queryArr; - assert.equal(queryArr.get('length'), 0, 'No records for this query'); - assert.equal(queryArr.get('isUpdating'), false, 'Record array isUpdating state updated'); - }); - - // initialize a record collection array and assert initial state - findPromise.then(_findArr => { - findArray = _findArr; - assert.equal(findArray.get('length'), 1, 'All records are included in collection array'); - }); - }); + queryArr = await store.query('person', { slice: 1 }); + findArray = await store.findAll('person'); + + assert.equal(queryArr.get('length'), 0, 'No records for this query'); + assert.equal(queryArr.get('isUpdating'), false, 'Record array isUpdating state updated'); + assert.equal(findArray.get('length'), 1, 'All records are included in collection array'); // a new element gets pushed in record array - run(() => { - array.push({ id: '2', type: 'person', attributes: { name: 'Scumbag Katz' } }); - queryArr.update().then(() => { - assert.equal(queryArr.get('length'), 1, 'The new record is returned and added in adapter populated array'); - assert.equal(queryArr.get('isUpdating'), false, 'Record array isUpdating state updated'); - assert.equal(findArray.get('length'), 2); - }); - }); + array.push({ id: '2', type: 'person', attributes: { name: 'Scumbag Katz' } }); + await queryArr.update(); + + assert.equal(queryArr.get('length'), 1, 'The new record is returned and added in adapter populated array'); + assert.equal(queryArr.get('isUpdating'), false, 'Record array isUpdating state updated'); + assert.equal(findArray.get('length'), 2, 'find returns 2 records'); // element gets removed - run(() => { - array.pop(0); - queryArr.update().then(() => { - assert.equal(queryArr.get('length'), 0, 'Record removed from array'); - // record not removed from the model collection - assert.equal(findArray.get('length'), 2, 'Record still remains in collection array'); - }); - }); + array.pop(0); + await queryArr.update(); + + assert.equal(queryArr.get('length'), 0, 'Record removed from array'); + // record not removed from the model collection + assert.equal(findArray.get('length'), 2, 'Record still remains in collection array'); }); }); diff --git a/packages/-ember-data/tests/integration/relationships/has-many-test.js b/packages/-ember-data/tests/integration/relationships/has-many-test.js index bf2f406ec47..3ca940990a8 100644 --- a/packages/-ember-data/tests/integration/relationships/has-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/has-many-test.js @@ -3743,7 +3743,7 @@ module('integration/relationships/has_many - Has-Many Relationships', function(h }); }); - test('deleteRecord + unloadRecord fun', function(assert) { + test('deleteRecord + unloadRecord', async function(assert) { let store = this.owner.lookup('service:store'); store.modelFor('user').reopen({ @@ -3754,87 +3754,82 @@ module('integration/relationships/has_many - Has-Many Relationships', function(h user: belongsTo('user', { inverse: null, async: false }), }); - run(() => { - store.push({ - data: [ - { - type: 'user', - id: 'user-1', - attributes: { - name: 'Adolfo Builes', - }, - relationships: { - posts: { - data: [ - { type: 'post', id: 'post-1' }, - { type: 'post', id: 'post-2' }, - { type: 'post', id: 'post-3' }, - { type: 'post', id: 'post-4' }, - { type: 'post', id: 'post-5' }, - ], - }, + store.push({ + data: [ + { + type: 'user', + id: 'user-1', + attributes: { + name: 'Adolfo Builes', + }, + relationships: { + posts: { + data: [ + { type: 'post', id: 'post-1' }, + { type: 'post', id: 'post-2' }, + { type: 'post', id: 'post-3' }, + { type: 'post', id: 'post-4' }, + { type: 'post', id: 'post-5' }, + ], }, }, - { type: 'post', id: 'post-1' }, - { type: 'post', id: 'post-2' }, - { type: 'post', id: 'post-3' }, - { type: 'post', id: 'post-4' }, - { type: 'post', id: 'post-5' }, - ], + }, + { type: 'post', id: 'post-1' }, + { type: 'post', id: 'post-2' }, + { type: 'post', id: 'post-3' }, + { type: 'post', id: 'post-4' }, + { type: 'post', id: 'post-5' }, + ], + }); + + let user = store.peekRecord('user', 'user-1'); + let posts = user.get('posts'); + + store.adapterFor('post').deleteRecord = function() { + // just acknowledge all deletes, but with a noop + return { data: null }; + }; + + assert.deepEqual( + posts.map(x => x.get('id')), + ['post-1', 'post-2', 'post-3', 'post-4', 'post-5'] + ); + + await store + .peekRecord('post', 'post-2') + .destroyRecord() + .then(record => { + return store.unloadRecord(record); }); - let user = store.peekRecord('user', 'user-1'); - let posts = user.get('posts'); + assert.deepEqual( + posts.map(x => x.get('id')), + ['post-1', 'post-3', 'post-4', 'post-5'] + ); - store.adapterFor('post').deleteRecord = function() { - // just acknowledge all deletes, but with a noop - return { data: null }; - }; + await store + .peekRecord('post', 'post-3') + .destroyRecord() + .then(record => { + return store.unloadRecord(record); + }); - assert.deepEqual( - posts.map(x => x.get('id')), - ['post-1', 'post-2', 'post-3', 'post-4', 'post-5'] - ); + assert.deepEqual( + posts.map(x => x.get('id')), + ['post-1', 'post-4', 'post-5'] + ); - return run(() => { - return store - .peekRecord('post', 'post-2') - .destroyRecord() - .then(record => { - return store.unloadRecord(record); - }); - }) - .then(() => { - assert.deepEqual( - posts.map(x => x.get('id')), - ['post-1', 'post-3', 'post-4', 'post-5'] - ); - return store - .peekRecord('post', 'post-3') - .destroyRecord() - .then(record => { - return store.unloadRecord(record); - }); - }) - .then(() => { - assert.deepEqual( - posts.map(x => x.get('id')), - ['post-1', 'post-4', 'post-5'] - ); - return store - .peekRecord('post', 'post-4') - .destroyRecord() - .then(record => { - return store.unloadRecord(record); - }); - }) - .then(() => { - assert.deepEqual( - posts.map(x => x.get('id')), - ['post-1', 'post-5'] - ); - }); - }); + await store + .peekRecord('post', 'post-4') + .destroyRecord() + .then(record => { + return store.unloadRecord(record); + }); + + assert.deepEqual( + posts.map(x => x.get('id')), + ['post-1', 'post-5'] + ); }); test('unloading and reloading a record with hasMany relationship - #3084', function(assert) { diff --git a/packages/-ember-data/tests/unit/model/relationships/record-array-test.js b/packages/-ember-data/tests/unit/model/relationships/record-array-test.js index fe93eb663b6..6b961c5a346 100644 --- a/packages/-ember-data/tests/unit/model/relationships/record-array-test.js +++ b/packages/-ember-data/tests/unit/model/relationships/record-array-test.js @@ -7,22 +7,24 @@ import { module, test } from 'qunit'; import DS from 'ember-data'; import { setupTest } from 'ember-qunit'; -module('unit/model/relationships - RecordArray', function(hooks) { - setupTest(hooks); +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; +import { recordIdentifierFor } from '@ember-data/store'; - test('updating the content of a RecordArray updates its content', function(assert) { - let Tag = DS.Model.extend({ - name: DS.attr('string'), - }); +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + module('unit/model/relationships - RecordArray', function(hooks) { + setupTest(hooks); + + test('updating the content of a RecordArray updates its content', async function(assert) { + let Tag = DS.Model.extend({ + name: DS.attr('string'), + }); - this.owner.register('model:tag', Tag); + this.owner.register('model:tag', Tag); - let store = this.owner.lookup('service:store'); - let tags; - let internalModels; + let store = this.owner.lookup('service:store'); + let tags; - run(() => { - internalModels = store._push({ + let records = store.push({ data: [ { type: 'tag', @@ -48,43 +50,50 @@ module('unit/model/relationships - RecordArray', function(hooks) { ], }); tags = DS.RecordArray.create({ - content: A(internalModels.slice(0, 2)), + content: A(records.map(r => recordIdentifierFor(r)).slice(0, 2)), store: store, modelName: 'tag', }); - }); - - let tag = tags.objectAt(0); - assert.equal(get(tag, 'name'), 'friendly', `precond - we're working with the right tags`); - run(() => set(tags, 'content', A(internalModels.slice(1, 3)))); - - tag = tags.objectAt(0); - assert.equal(get(tag, 'name'), 'smarmy', 'the lookup was updated'); - }); + let tag = tags.objectAt(0); + assert.equal(get(tag, 'name'), 'friendly', `precond - we're working with the right tags`); + + set( + tags, + 'content', + A( + records + .map(r => r._internalModel) + .slice(1, 3) + .map(im => im.identifier) + ) + ); + + tag = tags.objectAt(0); + assert.equal(get(tag, 'name'), 'smarmy', 'the lookup was updated'); + }); - test('can create child record from a hasMany relationship', function(assert) { - assert.expect(3); + test('can create child record from a hasMany relationship', async function(assert) { + assert.expect(3); - const Tag = DS.Model.extend({ - name: DS.attr('string'), - person: DS.belongsTo('person', { async: false }), - }); + const Tag = DS.Model.extend({ + name: DS.attr('string'), + person: DS.belongsTo('person', { async: false }), + }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tags: DS.hasMany('tag', { async: false }), - }); + const Person = DS.Model.extend({ + name: DS.attr('string'), + tags: DS.hasMany('tag', { async: false }), + }); - this.owner.register('model:tag', Tag); - this.owner.register('model:person', Person); + this.owner.register('model:tag', Tag); + this.owner.register('model:person', Person); - let store = this.owner.lookup('service:store'); - let adapter = store.adapterFor('application'); + let store = this.owner.lookup('service:store'); + let adapter = store.adapterFor('application'); - adapter.shouldBackgroundReloadRecord = () => false; + adapter.shouldBackgroundReloadRecord = () => false; - run(() => { store.push({ data: { type: 'person', @@ -94,22 +103,126 @@ module('unit/model/relationships - RecordArray', function(hooks) { }, }, }); + + let person = await store.findRecord('person', 1); + person.get('tags').createRecord({ name: 'cool' }); + + assert.equal(get(person, 'name'), 'Tom Dale', 'precond - retrieves person record from store'); + assert.equal(get(person, 'tags.length'), 1, 'tag is added to the parent record'); + assert.equal( + get(person, 'tags') + .objectAt(0) + .get('name'), + 'cool', + 'tag values are passed along' + ); + }); + }); +} else { + module('unit/model/relationships - RecordArray', function(hooks) { + setupTest(hooks); + + test('updating the content of a RecordArray updates its content', function(assert) { + let Tag = DS.Model.extend({ + name: DS.attr('string'), + }); + + this.owner.register('model:tag', Tag); + + let store = this.owner.lookup('service:store'); + let tags; + let internalModels; + + run(() => { + internalModels = store._push({ + data: [ + { + type: 'tag', + id: '5', + attributes: { + name: 'friendly', + }, + }, + { + type: 'tag', + id: '2', + attributes: { + name: 'smarmy', + }, + }, + { + type: 'tag', + id: '12', + attributes: { + name: 'oohlala', + }, + }, + ], + }); + tags = DS.RecordArray.create({ + content: A(internalModels.slice(0, 2)), + store: store, + modelName: 'tag', + }); + }); + + let tag = tags.objectAt(0); + assert.equal(get(tag, 'name'), 'friendly', `precond - we're working with the right tags`); + + run(() => set(tags, 'content', A(internalModels.slice(1, 3)))); + + tag = tags.objectAt(0); + assert.equal(get(tag, 'name'), 'smarmy', 'the lookup was updated'); }); - return run(() => { - return store.findRecord('person', 1).then(person => { - person.get('tags').createRecord({ name: 'cool' }); - - assert.equal(get(person, 'name'), 'Tom Dale', 'precond - retrieves person record from store'); - assert.equal(get(person, 'tags.length'), 1, 'tag is added to the parent record'); - assert.equal( - get(person, 'tags') - .objectAt(0) - .get('name'), - 'cool', - 'tag values are passed along' - ); + test('can create child record from a hasMany relationship', function(assert) { + assert.expect(3); + + const Tag = DS.Model.extend({ + name: DS.attr('string'), + person: DS.belongsTo('person', { async: false }), + }); + + const Person = DS.Model.extend({ + name: DS.attr('string'), + tags: DS.hasMany('tag', { async: false }), + }); + + this.owner.register('model:tag', Tag); + this.owner.register('model:person', Person); + + let store = this.owner.lookup('service:store'); + let adapter = store.adapterFor('application'); + + adapter.shouldBackgroundReloadRecord = () => false; + + run(() => { + store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Tom Dale', + }, + }, + }); + }); + + return run(() => { + return store.findRecord('person', 1).then(person => { + person.get('tags').createRecord({ name: 'cool' }); + + assert.equal(get(person, 'name'), 'Tom Dale', 'precond - retrieves person record from store'); + assert.equal(get(person, 'tags.length'), 1, 'tag is added to the parent record'); + assert.equal( + get(person, 'tags') + .objectAt(0) + .get('name'), + 'cool', + 'tag values are passed along' + ); + }); }); }); }); -}); +} diff --git a/packages/-ember-data/tests/unit/record-arrays/adapter-populated-record-array-test.js b/packages/-ember-data/tests/unit/record-arrays/adapter-populated-record-array-test.js index 9f61eedbe8f..fcc022a1e07 100644 --- a/packages/-ember-data/tests/unit/record-arrays/adapter-populated-record-array-test.js +++ b/packages/-ember-data/tests/unit/record-arrays/adapter-populated-record-array-test.js @@ -1,309 +1,668 @@ import { A } from '@ember/array'; import Evented from '@ember/object/evented'; import { run } from '@ember/runloop'; +import { settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; import RSVP from 'rsvp'; import DS from 'ember-data'; +import { setupTest } from 'ember-qunit'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; +import Model, { attr } from '@ember-data/model'; import { DEPRECATE_EVENTED_API_USAGE } from '@ember-data/private-build-infra/deprecations'; +import { recordIdentifierFor } from '@ember-data/store'; const { AdapterPopulatedRecordArray, RecordArrayManager } = DS; -module('unit/record-arrays/adapter-populated-record-array - DS.AdapterPopulatedRecordArray', function() { - function internalModelFor(record) { - let _internalModel = { - get id() { - return record.id; - }, - getRecord() { - return record; - }, - }; - - record._internalModel = _internalModel; - return _internalModel; +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + class Tag extends Model { + @attr() + name; } - test('default initial state', function(assert) { - let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); + module('unit/record-arrays/adapter-populated-record-array - DS.AdapterPopulatedRecordArray', function(hooks) { + setupTest(hooks); - assert.equal(recordArray.get('isLoaded'), false, 'expected isLoaded to be false'); - assert.equal(recordArray.get('modelName'), 'recordType'); - assert.deepEqual(recordArray.get('content'), []); - assert.strictEqual(recordArray.get('query'), null); - assert.strictEqual(recordArray.get('store'), null); - assert.strictEqual(recordArray.get('links'), null); - }); + test('default initial state', async function(assert) { + let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); - test('custom initial state', function(assert) { - let content = A([]); - let store = {}; - let recordArray = AdapterPopulatedRecordArray.create({ - modelName: 'apple', - isLoaded: true, - isUpdating: true, - content, - store, - query: 'some-query', - links: 'foo', + assert.equal(recordArray.get('isLoaded'), false, 'expected isLoaded to be false'); + assert.equal(recordArray.get('modelName'), 'recordType', 'has modelName'); + assert.deepEqual(recordArray.get('content'), [], 'has no content'); + assert.strictEqual(recordArray.get('query'), null, 'no query'); + assert.strictEqual(recordArray.get('store'), null, 'no store'); + assert.strictEqual(recordArray.get('links'), null, 'no links'); }); - assert.equal(recordArray.get('isLoaded'), true); - assert.equal(recordArray.get('isUpdating'), false); - assert.equal(recordArray.get('modelName'), 'apple'); - assert.equal(recordArray.get('content'), content); - assert.equal(recordArray.get('store'), store); - assert.equal(recordArray.get('query'), 'some-query'); - assert.strictEqual(recordArray.get('links'), 'foo'); - }); - test('#replace() throws error', function(assert) { - let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); + test('custom initial state', async function(assert) { + let content = A([]); + let store = {}; + let recordArray = AdapterPopulatedRecordArray.create({ + modelName: 'apple', + isLoaded: true, + isUpdating: true, + content, + store, + query: 'some-query', + links: 'foo', + }); + assert.equal(recordArray.get('isLoaded'), true); + assert.equal(recordArray.get('isUpdating'), false); + assert.equal(recordArray.get('modelName'), 'apple'); + assert.deepEqual(recordArray.get('content'), content); + assert.equal(recordArray.get('store'), store); + assert.equal(recordArray.get('query'), 'some-query'); + assert.strictEqual(recordArray.get('links'), 'foo'); + }); - assert.throws( - () => { - recordArray.replace(); - }, - Error('The result of a server query (on recordType) is immutable.'), - 'throws error' - ); - }); + test('#replace() throws error', function(assert) { + let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); - test('#update uses _update enabling query specific behavior', function(assert) { - let queryCalled = 0; - let deferred = RSVP.defer(); + assert.throws( + () => { + recordArray.replace(); + }, + Error('The result of a server query (on recordType) is immutable.'), + 'throws error' + ); + }); - const store = { - _query(modelName, query, array) { - queryCalled++; - assert.equal(modelName, 'recordType'); - assert.equal(query, 'some-query'); - assert.equal(array, recordArray); + test('#update uses _update enabling query specific behavior', async function(assert) { + let queryCalled = 0; + let deferred = RSVP.defer(); + + const store = { + _query(modelName, query, array) { + queryCalled++; + assert.equal(modelName, 'recordType'); + assert.equal(query, 'some-query'); + assert.equal(array, recordArray); + + return deferred.promise; + }, + }; + + let recordArray = AdapterPopulatedRecordArray.create({ + modelName: 'recordType', + store, + query: 'some-query', + }); - return deferred.promise; - }, - }; + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); - let recordArray = AdapterPopulatedRecordArray.create({ - modelName: 'recordType', - store, - query: 'some-query', - }); + assert.equal(queryCalled, 0); - assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + let updateResult = recordArray.update(); - assert.equal(queryCalled, 0); + assert.equal(queryCalled, 1); - let updateResult = recordArray.update(); + deferred.resolve('return value'); - assert.equal(queryCalled, 1); + assert.equal(recordArray.get('isUpdating'), true, 'should be updating'); - deferred.resolve('return value'); + return updateResult.then(result => { + assert.equal(result, 'return value'); + assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); + }); + }); - assert.equal(recordArray.get('isUpdating'), true, 'should be updating'); + // TODO: is this method required, i suspect store._query should be refactor so this is not needed + test('#_setIdentifiers', async function(assert) { + let didAddRecord = 0; + function add(array) { + didAddRecord++; + assert.equal(array, recordArray); + } - return updateResult.then(result => { - assert.equal(result, 'return value'); - assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); - }); - }); + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); - // TODO: is this method required, i suspect store._query should be refactor so this is not needed - test('#_setInternalModels', function(assert) { - let didAddRecord = 0; - function add(array) { - didAddRecord++; - assert.equal(array, recordArray); - } + const set = new Set(); + set.add = add; + let manager = new RecordArrayManager({ + store, + }); + manager.getRecordArraysForIdentifier = () => { + return set; + }; + + let recordArray = AdapterPopulatedRecordArray.create({ + query: 'some-query', + manager, + content: A(), + store, + }); - let recordArray = AdapterPopulatedRecordArray.create({ - query: 'some-query', - manager: new RecordArrayManager({}), - }); + let model1 = { + type: 'tag', + id: '1', + }; + let model2 = { + type: 'tag', + id: '2', + }; + + let [record1, record2] = store.push({ + data: [model1, model2], + }); - let model1 = internalModelFor({ id: 1 }); - let model2 = internalModelFor({ id: 2 }); + let identifier1 = recordIdentifierFor(record1); + let identifier2 = recordIdentifierFor(record2); - model1._recordArrays = { add }; - model2._recordArrays = { add }; + assert.equal(didAddRecord, 0, 'no records should have been added yet'); - assert.equal(didAddRecord, 0, 'no records should have been added yet'); + let didLoad = 0; + if (DEPRECATE_EVENTED_API_USAGE) { + recordArray.on('didLoad', function() { + didLoad++; + }); + } + + let links = { foo: 1 }; + let meta = { bar: 2 }; - let didLoad = 0; - if (DEPRECATE_EVENTED_API_USAGE) { - recordArray.on('didLoad', function() { - didLoad++; + let result = recordArray._setIdentifiers([identifier1, identifier2], { + links, + meta, }); - } - let links = { foo: 1 }; - let meta = { bar: 2 }; - - run(() => { - assert.equal( - recordArray._setInternalModels([model1, model2], { - links, - meta, - }), - undefined, - '_setInternalModels should have no return value' - ); + assert.equal(result, undefined, '_setIdentifiers should have no return value'); assert.equal(didAddRecord, 2, 'two records should have been added'); assert.deepEqual( recordArray.toArray(), - [model1, model2].map(x => x.getRecord()), - 'should now contain the loaded records' + [record1, record2], + 'should now contain the loaded records by identifier' ); if (DEPRECATE_EVENTED_API_USAGE) { assert.equal(didLoad, 0, 'didLoad event should not have fired'); } - assert.equal(recordArray.get('links').foo, 1); - assert.equal(recordArray.get('meta').bar, 2); - }); - if (DEPRECATE_EVENTED_API_USAGE) { - assert.equal(didLoad, 1, 'didLoad event should have fired once'); - } - assert.expectDeprecation({ - id: 'ember-data:evented-api-usage', + assert.equal(recordArray.get('links').foo, 1, 'has links'); + assert.equal(recordArray.get('meta').bar, 2, 'has meta'); + + await settled(); + + if (DEPRECATE_EVENTED_API_USAGE) { + assert.equal(didLoad, 1, 'didLoad event should have fired once'); + } + assert.expectDeprecation({ + id: 'ember-data:evented-api-usage', + }); }); - }); - test('change events when receiving a new query payload', function(assert) { - assert.expect(38); + test('change events when receiving a new query payload', async function(assert) { + assert.expect(38); - let arrayDidChange = 0; - let contentDidChange = 0; - let didAddRecord = 0; + let arrayDidChange = 0; + let contentDidChange = 0; + let didAddRecord = 0; - function add(array) { - didAddRecord++; - assert.equal(array, recordArray); - } + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); - function del(array) { - assert.equal(array, recordArray); - } + function add(array) { + didAddRecord++; + assert.equal(array, recordArray); + } - // we need Evented to gain access to the @array:change event - let recordArray = AdapterPopulatedRecordArray.extend(Evented).create({ - query: 'some-query', - manager: new RecordArrayManager({}), - }); + function del(array) { + assert.equal(array, recordArray); + } + + const set = new Set(); + set.add = add; + set.delete = del; + let manager = new RecordArrayManager({ + store, + }); + manager.getRecordArraysForIdentifier = () => { + return set; + }; + let recordArray = AdapterPopulatedRecordArray.extend(Evented).create({ + query: 'some-query', + manager, + content: A(), + store, + }); + + let model1 = { + type: 'tag', + id: '1', + attributes: { + name: 'Scumbag Dale', + }, + }; + let model2 = { + type: 'tag', + id: '2', + attributes: { + name: 'Scumbag Katz', + }, + }; + + let [record1, record2] = store.push({ + data: [model1, model2], + }); + + recordArray._setIdentifiers([recordIdentifierFor(record1), recordIdentifierFor(record2)], {}); + + assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Dale', 'Scumbag Katz'] + ); + + assert.equal(arrayDidChange, 0, 'array should not yet have emitted a change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + recordArray.addObserver('content', function() { + contentDidChange++; + }); + + recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { + arrayDidChange++; + + // first time invoked + assert.equal(array, recordArray, 'should be same record array as above'); + assert.equal(startIdx, 0, 'expected startIdx'); + assert.equal(removeAmt, 2, 'expected removeAmt'); + assert.equal(addAmt, 2, 'expected addAmt'); + }); + + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + + assert.equal(arrayDidChange, 0); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + arrayDidChange = 0; + contentDidChange = 0; + didAddRecord = 0; + + let model3 = { + type: 'tag', + id: '3', + attributes: { + name: 'Scumbag Penner', + }, + }; + let model4 = { + type: 'tag', + id: '4', + attributes: { + name: 'Scumbag Hamilton', + }, + }; + + let [record3, record4] = store.push({ + data: [model3, model4], + }); + + recordArray._setIdentifiers([recordIdentifierFor(record3), recordIdentifierFor(record4)], {}); + + assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); + + assert.equal(arrayDidChange, 1, 'record array should have omitted ONE change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Penner', 'Scumbag Hamilton'] + ); + + arrayDidChange = 0; // reset change event counter + contentDidChange = 0; // reset change event counter + didAddRecord = 0; + + recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { + arrayDidChange++; + + assert.equal(array, recordArray, 'should be same recordArray as above'); + assert.equal(startIdx, 0, 'expected startIdx'); + assert.equal(removeAmt, 2, 'expected removeAmt'); + assert.equal(addAmt, 1, 'expected addAmt'); + }); + + // re-query + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + + assert.equal(arrayDidChange, 0, 'record array should not yet have omitted a change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + let model5 = { + type: 'tag', + id: '5', + attributes: { + name: 'Scumbag Penner', + }, + }; + + let record5 = store.push({ + data: model5, + }); + + recordArray._setIdentifiers([recordIdentifierFor(record5)], {}); + + assert.equal(didAddRecord, 1, 'expected 0 didAddRecord'); - let model1 = internalModelFor({ id: '1', name: 'Scumbag Dale' }); - let model2 = internalModelFor({ id: '2', name: 'Scumbag Katz' }); + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not longer be updating'); - model1._recordArrays = { add, delete: del }; - model2._recordArrays = { add, delete: del }; + assert.equal(arrayDidChange, 1, 'record array should have emitted one change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); - run(() => { - recordArray._setInternalModels([model1, model2], {}); + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Penner'] + ); + assert.expectDeprecation({ + id: 'ember-data:evented-api-usage', + count: 1, + }); }); + }); +} else { + module('unit/record-arrays/adapter-populated-record-array - DS.AdapterPopulatedRecordArray', function() { + function internalModelFor(record) { + let _internalModel = { + get id() { + return record.id; + }, + getRecord() { + return record; + }, + }; + + record._internalModel = _internalModel; + return _internalModel; + } - assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); - assert.deepEqual( - recordArray.map(x => x.name), - ['Scumbag Dale', 'Scumbag Katz'] - ); + test('default initial state', function(assert) { + let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); - assert.equal(arrayDidChange, 0, 'array should not yet have emitted a change event'); - assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + assert.equal(recordArray.get('isLoaded'), false, 'expected isLoaded to be false'); + assert.equal(recordArray.get('modelName'), 'recordType'); + assert.deepEqual(recordArray.get('content'), []); + assert.strictEqual(recordArray.get('query'), null); + assert.strictEqual(recordArray.get('store'), null); + assert.strictEqual(recordArray.get('links'), null); + }); - recordArray.addObserver('content', function() { - contentDidChange++; + test('custom initial state', function(assert) { + let content = A([]); + let store = {}; + let recordArray = AdapterPopulatedRecordArray.create({ + modelName: 'apple', + isLoaded: true, + isUpdating: true, + content, + store, + query: 'some-query', + links: 'foo', + }); + assert.equal(recordArray.get('isLoaded'), true); + assert.equal(recordArray.get('isUpdating'), false); + assert.equal(recordArray.get('modelName'), 'apple'); + assert.equal(recordArray.get('content'), content); + assert.equal(recordArray.get('store'), store); + assert.equal(recordArray.get('query'), 'some-query'); + assert.strictEqual(recordArray.get('links'), 'foo'); }); - recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { - arrayDidChange++; + test('#replace() throws error', function(assert) { + let recordArray = AdapterPopulatedRecordArray.create({ modelName: 'recordType' }); - // first time invoked - assert.equal(array, recordArray, 'should be same record array as above'); - assert.equal(startIdx, 0, 'expected startIdx'); - assert.equal(removeAmt, 2, 'expcted removeAmt'); - assert.equal(addAmt, 2, 'expected addAmt'); + assert.throws( + () => { + recordArray.replace(); + }, + Error('The result of a server query (on recordType) is immutable.'), + 'throws error' + ); }); - assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); - assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + test('#update uses _update enabling query specific behavior', function(assert) { + let queryCalled = 0; + let deferred = RSVP.defer(); + + const store = { + _query(modelName, query, array) { + queryCalled++; + assert.equal(modelName, 'recordType'); + assert.equal(query, 'some-query'); + assert.equal(array, recordArray); + + return deferred.promise; + }, + }; + + let recordArray = AdapterPopulatedRecordArray.create({ + modelName: 'recordType', + store, + query: 'some-query', + }); - assert.equal(arrayDidChange, 0); - assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); - arrayDidChange = 0; - contentDidChange = 0; - didAddRecord = 0; + assert.equal(queryCalled, 0); - let model3 = internalModelFor({ id: '3', name: 'Scumbag Penner' }); - let model4 = internalModelFor({ id: '4', name: 'Scumbag Hamilton' }); + let updateResult = recordArray.update(); - model3._recordArrays = { add, delete: del }; - model4._recordArrays = { add, delete: del }; + assert.equal(queryCalled, 1); - run(() => { - // re-query - recordArray._setInternalModels([model3, model4], {}); + deferred.resolve('return value'); + + assert.equal(recordArray.get('isUpdating'), true, 'should be updating'); + + return updateResult.then(result => { + assert.equal(result, 'return value'); + assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); + }); }); - assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); - assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); - assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); + // TODO: is this method required, i suspect store._query should be refactor so this is not needed + test('#_setInternalModels', function(assert) { + let didAddRecord = 0; + function add(array) { + didAddRecord++; + assert.equal(array, recordArray); + } + + let recordArray = AdapterPopulatedRecordArray.create({ + query: 'some-query', + manager: new RecordArrayManager({}), + }); - assert.equal(arrayDidChange, 1, 'record array should have omitted ONE change event'); - assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + let model1 = internalModelFor({ id: 1 }); + let model2 = internalModelFor({ id: 2 }); - assert.deepEqual( - recordArray.map(x => x.name), - ['Scumbag Penner', 'Scumbag Hamilton'] - ); + model1._recordArrays = { add }; + model2._recordArrays = { add }; - arrayDidChange = 0; // reset change event counter - contentDidChange = 0; // reset change event counter - didAddRecord = 0; + assert.equal(didAddRecord, 0, 'no records should have been added yet'); - recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { - arrayDidChange++; + let didLoad = 0; + if (DEPRECATE_EVENTED_API_USAGE) { + recordArray.on('didLoad', function() { + didLoad++; + }); + } - // first time invoked - assert.equal(array, recordArray, 'should be same recordArray as above'); - assert.equal(startIdx, 0, 'expected startIdx'); - assert.equal(removeAmt, 2, 'expcted removeAmt'); - assert.equal(addAmt, 1, 'expected addAmt'); + let links = { foo: 1 }; + let meta = { bar: 2 }; + + run(() => { + assert.equal( + recordArray._setInternalModels([model1, model2], { + links, + meta, + }), + undefined, + '_setInternalModels should have no return value' + ); + + assert.equal(didAddRecord, 2, 'two records should have been added'); + + assert.deepEqual( + recordArray.toArray(), + [model1, model2].map(x => x.getRecord()), + 'should now contain the loaded records' + ); + + if (DEPRECATE_EVENTED_API_USAGE) { + assert.equal(didLoad, 0, 'didLoad event should not have fired'); + } + assert.equal(recordArray.get('links').foo, 1); + assert.equal(recordArray.get('meta').bar, 2); + }); + if (DEPRECATE_EVENTED_API_USAGE) { + assert.equal(didLoad, 1, 'didLoad event should have fired once'); + } + assert.expectDeprecation({ + id: 'ember-data:evented-api-usage', + }); }); - // re-query - assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); - assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + test('change events when receiving a new query payload', function(assert) { + assert.expect(38); + + let arrayDidChange = 0; + let contentDidChange = 0; + let didAddRecord = 0; - assert.equal(arrayDidChange, 0, 'record array should not yet have omitted a change event'); - assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + function add(array) { + didAddRecord++; + assert.equal(array, recordArray); + } - let model5 = internalModelFor({ id: '3', name: 'Scumbag Penner' }); + function del(array) { + assert.equal(array, recordArray); + } - model5._recordArrays = { add, delete: del }; + // we need Evented to gain access to the @array:change event + let recordArray = AdapterPopulatedRecordArray.extend(Evented).create({ + query: 'some-query', + manager: new RecordArrayManager({}), + }); - run(() => { - recordArray._setInternalModels([model5], {}); - }); + let model1 = internalModelFor({ id: '1', name: 'Scumbag Dale' }); + let model2 = internalModelFor({ id: '2', name: 'Scumbag Katz' }); + + model1._recordArrays = { add, delete: del }; + model2._recordArrays = { add, delete: del }; + + run(() => { + recordArray._setInternalModels([model1, model2], {}); + }); - assert.equal(didAddRecord, 1, 'expected 0 didAddRecord'); + assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Dale', 'Scumbag Katz'] + ); + + assert.equal(arrayDidChange, 0, 'array should not yet have emitted a change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + recordArray.addObserver('content', function() { + contentDidChange++; + }); + + recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { + arrayDidChange++; + + // first time invoked + assert.equal(array, recordArray, 'should be same record array as above'); + assert.equal(startIdx, 0, 'expected startIdx'); + assert.equal(removeAmt, 2, 'expcted removeAmt'); + assert.equal(addAmt, 2, 'expected addAmt'); + }); + + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + + assert.equal(arrayDidChange, 0); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + arrayDidChange = 0; + contentDidChange = 0; + didAddRecord = 0; + + let model3 = internalModelFor({ id: '3', name: 'Scumbag Penner' }); + let model4 = internalModelFor({ id: '4', name: 'Scumbag Hamilton' }); + + model3._recordArrays = { add, delete: del }; + model4._recordArrays = { add, delete: del }; + + run(() => { + // re-query + recordArray._setInternalModels([model3, model4], {}); + }); + + assert.equal(didAddRecord, 2, 'expected 2 didAddRecords'); + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should no longer be updating'); + + assert.equal(arrayDidChange, 1, 'record array should have omitted ONE change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); - assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); - assert.equal(recordArray.get('isUpdating'), false, 'should not longer be updating'); + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Penner', 'Scumbag Hamilton'] + ); + + arrayDidChange = 0; // reset change event counter + contentDidChange = 0; // reset change event counter + didAddRecord = 0; + + recordArray.one('@array:change', function(array, startIdx, removeAmt, addAmt) { + arrayDidChange++; + + // first time invoked + assert.equal(array, recordArray, 'should be same recordArray as above'); + assert.equal(startIdx, 0, 'expected startIdx'); + assert.equal(removeAmt, 2, 'expcted removeAmt'); + assert.equal(addAmt, 1, 'expected addAmt'); + }); + + // re-query + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not yet be updating'); + + assert.equal(arrayDidChange, 0, 'record array should not yet have omitted a change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); - assert.equal(arrayDidChange, 1, 'record array should have emitted one change event'); - assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + let model5 = internalModelFor({ id: '3', name: 'Scumbag Penner' }); - assert.deepEqual( - recordArray.map(x => x.name), - ['Scumbag Penner'] - ); - assert.expectDeprecation({ - id: 'ember-data:evented-api-usage', - count: 1, + model5._recordArrays = { add, delete: del }; + + run(() => { + recordArray._setInternalModels([model5], {}); + }); + + assert.equal(didAddRecord, 1, 'expected 0 didAddRecord'); + + assert.equal(recordArray.get('isLoaded'), true, 'should be considered loaded'); + assert.equal(recordArray.get('isUpdating'), false, 'should not longer be updating'); + + assert.equal(arrayDidChange, 1, 'record array should have emitted one change event'); + assert.equal(contentDidChange, 0, 'recordArray.content should not have changed'); + + assert.deepEqual( + recordArray.map(x => x.name), + ['Scumbag Penner'] + ); + assert.expectDeprecation({ + id: 'ember-data:evented-api-usage', + count: 1, + }); }); }); -}); +} diff --git a/packages/-ember-data/tests/unit/record-arrays/record-array-test.js b/packages/-ember-data/tests/unit/record-arrays/record-array-test.js index 051aadf0f8f..2360d4d06c4 100644 --- a/packages/-ember-data/tests/unit/record-arrays/record-array-test.js +++ b/packages/-ember-data/tests/unit/record-arrays/record-array-test.js @@ -1,339 +1,391 @@ import { A } from '@ember/array'; import { get } from '@ember/object'; import { run } from '@ember/runloop'; +import { settled } from '@ember/test-helpers'; import { module, test } from 'qunit'; -import RSVP from 'rsvp'; +import RSVP, { resolve } from 'rsvp'; import DS from 'ember-data'; +import { setupTest } from 'ember-qunit'; + +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; +import Model, { attr } from '@ember-data/model'; +import { recordIdentifierFor } from '@ember-data/store'; const { RecordArray } = DS; -module('unit/record-arrays/record-array - DS.RecordArray', function() { - test('default initial state', function(assert) { - let recordArray = RecordArray.create({ modelName: 'recordType' }); +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + class Tag extends Model { + @attr + name; + } - assert.equal(get(recordArray, 'isLoaded'), false); - assert.equal(get(recordArray, 'isUpdating'), false); - assert.equal(get(recordArray, 'modelName'), 'recordType'); - assert.strictEqual(get(recordArray, 'content'), null); - assert.strictEqual(get(recordArray, 'store'), null); - }); + module('unit/record-arrays/record-array - DS.RecordArray', function(hooks) { + setupTest(hooks); - test('custom initial state', function(assert) { - let content = A(); - let store = {}; - let recordArray = RecordArray.create({ - modelName: 'apple', - isLoaded: true, - isUpdating: true, - content, - store, + test('default initial state', async function(assert) { + let recordArray = RecordArray.create({ modelName: 'recordType' }); + + assert.equal(get(recordArray, 'isLoaded'), false, 'record is not loaded'); + assert.equal(get(recordArray, 'isUpdating'), false, 'record is not updating'); + assert.equal(get(recordArray, 'modelName'), 'recordType', 'has modelName'); + assert.equal(get(recordArray, 'content'), undefined, 'content is not defined'); + assert.strictEqual(get(recordArray, 'store'), null, 'no store with recordArray'); }); - assert.equal(get(recordArray, 'isLoaded'), true); - assert.equal(get(recordArray, 'isUpdating'), false); // cannot set as default value: - assert.equal(get(recordArray, 'modelName'), 'apple'); - assert.equal(get(recordArray, 'content'), content); - assert.equal(get(recordArray, 'store'), store); - }); - test('#replace() throws error', function(assert) { - let recordArray = RecordArray.create({ modelName: 'recordType' }); + test('custom initial state', async function(assert) { + let content = A(); + let store = {}; + let recordArray = RecordArray.create({ + modelName: 'apple', + isLoaded: true, + isUpdating: true, + content, + store, + }); + assert.equal(get(recordArray, 'isLoaded'), true); + assert.equal(get(recordArray, 'isUpdating'), false); // cannot set as default value: + assert.equal(get(recordArray, 'modelName'), 'apple'); + assert.deepEqual(get(recordArray, 'content'), content); + assert.equal(get(recordArray, 'store'), store); + }); - assert.throws( - () => { - recordArray.replace(); - }, - Error('The result of a server query (for all recordType types) is immutable. To modify contents, use toArray()'), - 'throws error' - ); - }); + test('#replace() throws error', async function(assert) { + let recordArray = RecordArray.create({ modelName: 'recordType' }); - test('#objectAtContent', function(assert) { - let content = A([ - { - getRecord() { - return 'foo'; - }, - }, - { - getRecord() { - return 'bar'; - }, - }, - { - getRecord() { - return 'baz'; + assert.throws( + () => { + recordArray.replace(); }, - }, - ]); + Error( + 'The result of a server query (for all recordType types) is immutable. To modify contents, use toArray()' + ), + 'throws error' + ); + }); - let recordArray = RecordArray.create({ - modelName: 'recordType', - content, + test('#objectAtContent', async function(assert) { + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); + + let records = store.push({ + data: [ + { + type: 'tag', + id: '1', + }, + { + type: 'tag', + id: '3', + }, + { + type: 'tag', + id: '5', + }, + ], + }); + + let recordArray = RecordArray.create({ + modelName: 'recordType', + content: A(records.map(r => recordIdentifierFor(r))), + store, + }); + + assert.equal(get(recordArray, 'length'), 3); + assert.equal(recordArray.objectAtContent(0).id, '1'); + assert.equal(recordArray.objectAtContent(1).id, '3'); + assert.equal(recordArray.objectAtContent(2).id, '5'); + assert.strictEqual(recordArray.objectAtContent(3), undefined); }); - assert.equal(get(recordArray, 'length'), 3); - assert.equal(recordArray.objectAtContent(0), 'foo'); - assert.equal(recordArray.objectAtContent(1), 'bar'); - assert.equal(recordArray.objectAtContent(2), 'baz'); - assert.strictEqual(recordArray.objectAtContent(3), undefined); - }); + test('#update', async function(assert) { + let findAllCalled = 0; + let deferred = RSVP.defer(); - test('#update', function(assert) { - let findAllCalled = 0; - let deferred = RSVP.defer(); - - const store = { - findAll(modelName, options) { - findAllCalled++; - assert.equal(modelName, 'recordType'); - assert.equal(options.reload, true, 'options should contain reload: true'); - return deferred.promise; - }, - }; - - let recordArray = RecordArray.create({ - modelName: 'recordType', - store, - }); + const store = { + findAll(modelName, options) { + findAllCalled++; + assert.equal(modelName, 'recordType'); + assert.equal(options.reload, true, 'options should contain reload: true'); + return deferred.promise; + }, + }; - assert.equal(get(recordArray, 'isUpdating'), false, 'should not yet be updating'); + let recordArray = RecordArray.create({ + modelName: 'recordType', + store, + }); - assert.equal(findAllCalled, 0); + assert.equal(get(recordArray, 'isUpdating'), false, 'should not yet be updating'); - let updateResult = recordArray.update(); + assert.equal(findAllCalled, 0); - assert.equal(findAllCalled, 1); + let updateResult = recordArray.update(); - deferred.resolve('return value'); + assert.equal(findAllCalled, 1); - assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); + deferred.resolve('return value'); - return updateResult.then(result => { - assert.equal(result, 'return value'); - assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); - }); - }); + assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); - test('#update while updating', function(assert) { - let findAllCalled = 0; - let deferred = RSVP.defer(); - const store = { - findAll(modelName, options) { - findAllCalled++; - return deferred.promise; - }, - }; - - let recordArray = RecordArray.create({ - modelName: { modelName: 'recordType' }, - store, + return updateResult.then(result => { + assert.equal(result, 'return value'); + assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); + }); }); - assert.equal(get(recordArray, 'isUpdating'), false, 'should not be updating'); - assert.equal(findAllCalled, 0); + test('#update while updating', async function(assert) { + let findAllCalled = 0; + let deferred = RSVP.defer(); + const store = { + findAll(modelName, options) { + findAllCalled++; + return deferred.promise; + }, + }; - let updateResult1 = recordArray.update(); + let recordArray = RecordArray.create({ + modelName: { modelName: 'recordType' }, + store, + }); - assert.equal(findAllCalled, 1); + assert.equal(get(recordArray, 'isUpdating'), false, 'should not be updating'); + assert.equal(findAllCalled, 0); - let updateResult2 = recordArray.update(); + let updateResult1 = recordArray.update(); - assert.equal(findAllCalled, 1); + assert.equal(findAllCalled, 1); - assert.equal(updateResult1, updateResult2); + let updateResult2 = recordArray.update(); - deferred.resolve('return value'); + assert.equal(findAllCalled, 1); - assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); + assert.equal(updateResult1, updateResult2); - return updateResult1.then(result => { - assert.equal(result, 'return value'); - assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); - }); - }); + deferred.resolve('return value'); + + assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); - test('#_pushInternalModels', function(assert) { - let content = A(); - let recordArray = RecordArray.create({ - content, + return updateResult1.then(result => { + assert.equal(result, 'return value'); + assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); + }); }); - let model1 = { - id: 1, - getRecord() { - return 'model-1'; - }, - }; - let model2 = { - id: 2, - getRecord() { - return 'model-2'; - }, - }; - let model3 = { - id: 3, - getRecord() { - return 'model-3'; - }, - }; - - assert.equal(recordArray._pushInternalModels([model1]), undefined, '_pushInternalModels has no return value'); - assert.deepEqual(content, [model1], 'now contains model1'); - - recordArray._pushInternalModels([model1]); - assert.deepEqual( - content, - [model1, model1], - 'allows duplicates, because record-array-manager via internalModel._recordArrays ensures no duplicates, this layer should not double check' - ); - - recordArray._removeInternalModels([model1]); - recordArray._pushInternalModels([model1]); - - // can add multiple models at once - recordArray._pushInternalModels([model2, model3]); - assert.deepEqual(content, [model1, model2, model3], 'now contains model1, model2, model3'); - }); + test('#_pushIdentifiers', async function(assert) { + let content = A(); + let recordArray = RecordArray.create({ + content, + }); - test('#_removeInternalModels', function(assert) { - let content = A(); - let recordArray = RecordArray.create({ - content, - }); + let model1 = { + id: 1, + identifier: { lid: '@ember-data:lid-model-1' }, + getRecord() { + return this; + }, + }; + let model2 = { + id: 2, + identifier: { lid: '@ember-data:lid-model-2' }, + getRecord() { + return this; + }, + }; + let model3 = { + id: 3, + identifier: { lid: '@ember-data:lid-model-3' }, + getRecord() { + return this; + }, + }; - let model1 = { - id: 1, - getRecord() { - return 'model-1'; - }, - }; - let model2 = { - id: 2, - getRecord() { - return 'model-2'; - }, - }; - let model3 = { - id: 3, - getRecord() { - return 'model-3'; - }, - }; + assert.equal( + recordArray._pushIdentifiers([model1.identifier]), + undefined, + '_pushIdentifiers has no return value' + ); + assert.deepEqual(recordArray.get('content'), [model1.identifier], 'now contains model1'); - assert.equal(content.length, 0); - assert.equal(recordArray._removeInternalModels([model1]), undefined, '_removeInternalModels has no return value'); - assert.deepEqual(content, [], 'now contains no models'); + recordArray._pushIdentifiers([model1.identifier]); + assert.deepEqual( + recordArray.get('content'), + [model1.identifier, model1.identifier], + 'allows duplicates, because record-array-manager ensures no duplicates, this layer should not double check' + ); - recordArray._pushInternalModels([model1, model2]); + recordArray._removeIdentifiers([model1.identifier]); + recordArray._pushIdentifiers([model1.identifier]); - assert.deepEqual(content, [model1, model2], 'now contains model1, model2,'); - assert.equal(recordArray._removeInternalModels([model1]), undefined, '_removeInternalModels has no return value'); - assert.deepEqual(content, [model2], 'now only contains model2'); - assert.equal(recordArray._removeInternalModels([model2]), undefined, '_removeInternalModels has no return value'); - assert.deepEqual(content, [], 'now contains no models'); + // can add multiple models at once + recordArray._pushIdentifiers([model2.identifier, model3.identifier]); + assert.deepEqual( + recordArray.get('content'), + [model1.identifier, model2.identifier, model3.identifier], + 'now contains model1, model2, model3' + ); + }); - recordArray._pushInternalModels([model1, model2, model3]); + test('#_removeIdentifiers', async function(assert) { + let content = A(); + let recordArray = RecordArray.create({ + content, + }); - assert.equal( - recordArray._removeInternalModels([model1, model3]), - undefined, - '_removeInternalModels has no return value' - ); + let model1 = { + id: 1, + identifier: { lid: '@ember-data:lid-model-1' }, + getRecord() { + return 'model-1'; + }, + }; + let model2 = { + id: 2, + identifier: { lid: '@ember-data:lid-model-2' }, + getRecord() { + return 'model-2'; + }, + }; + let model3 = { + id: 3, + identifier: { lid: '@ember-data:lid-model-3' }, + getRecord() { + return 'model-3'; + }, + }; - assert.deepEqual(content, [model2], 'now contains model2'); - assert.equal(recordArray._removeInternalModels([model2]), undefined, '_removeInternalModels has no return value'); - assert.deepEqual(content, [], 'now contains no models'); - }); + assert.equal(recordArray.get('content').length, 0); + assert.equal( + recordArray._removeIdentifiers([model1.identifier]), + undefined, + '_removeIdentifiers has no return value' + ); + assert.deepEqual(recordArray.get('content'), [], 'now contains no models'); - class FakeInternalModel { - constructor(record) { - this._record = record; - this.__recordArrays = null; - } + recordArray._pushIdentifiers([model1.identifier, model2.identifier]); - get _recordArrays() { - return this.__recordArrays; - } + assert.deepEqual( + recordArray.get('content'), + [model1.identifier, model2.identifier], + 'now contains model1, model2,' + ); + assert.equal( + recordArray._removeIdentifiers([model1.identifier]), + undefined, + '_removeIdentifiers has no return value' + ); + assert.deepEqual(recordArray.get('content'), [model2.identifier], 'now only contains model2'); + assert.equal( + recordArray._removeIdentifiers([model2.identifier]), + undefined, + '_removeIdentifiers has no return value' + ); + assert.deepEqual(recordArray.get('content'), [], 'now contains no models'); - getRecord() { - return this._record; - } + recordArray._pushIdentifiers([model1.identifier, model2.identifier, model3.identifier]); - createSnapshot() { - return this._record; - } - } + assert.equal( + recordArray._removeIdentifiers([model1.identifier, model3.identifier]), + undefined, + '_removeIdentifiers has no return value' + ); - function internalModelFor(record) { - return new FakeInternalModel(record); - } + assert.deepEqual(recordArray.get('content'), [model2.identifier], 'now contains model2'); + assert.equal( + recordArray._removeIdentifiers([model2.identifier]), + undefined, + '_removeIdentifiers has no return value' + ); + assert.deepEqual(recordArray.get('content'), [], 'now contains no models'); + }); - test('#save', function(assert) { - let model1 = { - save() { + test('#save', async function(assert) { + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); + + let model1 = { + id: '1', + type: 'tag', + }; + let model2 = { + id: '2', + type: 'tag', + save() { + model2Saved++; + return this; + }, + }; + + let [record1, record2] = store.push({ + data: [model1, model2], + }); + let identifiers = A([recordIdentifierFor(record1), recordIdentifierFor(record2)]); + let recordArray = RecordArray.create({ + content: identifiers, + store, + }); + record1._internalModel.save = () => { model1Saved++; - return this; - }, - }; - let model2 = { - save() { + return resolve(this); + }; + record2._internalModel.save = () => { model2Saved++; - return this; - }, - }; - let content = A([internalModelFor(model1), internalModelFor(model2)]); - - let recordArray = RecordArray.create({ - content, - }); + return resolve(this); + }; - let model1Saved = 0; - let model2Saved = 0; + let model1Saved = 0; + let model2Saved = 0; - assert.equal(model1Saved, 0); - assert.equal(model2Saved, 0); + assert.equal(model1Saved, 0, 'save not yet called'); + assert.equal(model2Saved, 0, 'save not yet called'); - let result = recordArray.save(); + let result = recordArray.save(); - assert.equal(model1Saved, 1); - assert.equal(model2Saved, 1); + assert.equal(model1Saved, 1, 'save was called for model1'); + assert.equal(model2Saved, 1, 'save was called for mode2'); - return result.then(result => { - assert.equal(result, result, 'save promise should fulfill with the original recordArray'); + const r = await result; + assert.equal(r.id, result.id, 'save promise should fulfill with the original recordArray'); }); - }); - - test('#destroy', function(assert) { - let didUnregisterRecordArray = 0; - let didDissociatieFromOwnRecords = 0; - let model1 = {}; - let internalModel1 = internalModelFor(model1); - // TODO: this will be removed once we fix ownership related memory leaks. - internalModel1.__recordArrays = { - delete(array) { + test('#destroy', async function(assert) { + let didUnregisterRecordArray = 0; + let didDissociatieFromOwnRecords = 0; + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); + + let model1 = { + id: 1, + type: 'tag', + }; + let record = store.push({ + data: model1, + }); + + const set = new Set(); + set.delete = array => { didDissociatieFromOwnRecords++; assert.equal(array, recordArray); - }, - }; - // end TODO: - - let recordArray = RecordArray.create({ - content: A([internalModel1]), - manager: { - unregisterRecordArray(_recordArray) { - didUnregisterRecordArray++; - assert.equal(recordArray, _recordArray); + }; + + let recordArray = RecordArray.create({ + content: A([recordIdentifierFor(record)]), + store, + manager: { + getRecordArraysForIdentifier() { + return set; + }, + unregisterRecordArray(_recordArray) { + didUnregisterRecordArray++; + assert.equal(recordArray, _recordArray); + }, }, - }, - }); + }); - assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); - assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); + assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); + assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); - run(() => { assert.equal(get(recordArray, 'length'), 1, 'before destroy, length should be 1'); assert.equal(didUnregisterRecordArray, 0, 'before destroy, we should not yet have unregisterd the record array'); assert.equal( @@ -342,70 +394,93 @@ module('unit/record-arrays/record-array - DS.RecordArray', function() { 'before destroy, we should not yet have dissociated from own record array' ); recordArray.destroy(); + await settled(); + + assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); + assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); + + assert.strictEqual(get(recordArray, 'content'), null); + assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); + assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); }); - assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); - assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); + test('#_createSnapshot', async function(assert) { + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); - assert.strictEqual(get(recordArray, 'content'), null); - assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); - assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); - }); + let model1 = { + id: 1, + type: 'tag', + }; - test('#_createSnapshot', function(assert) { - let model1 = { - id: 1, - }; + let model2 = { + id: 2, + type: 'tag', + }; + let records = store.push({ + data: [model1, model2], + }); - let model2 = { - id: 2, - }; + let recordArray = RecordArray.create({ + content: A(records.map(r => recordIdentifierFor(r))), + store, + }); - let content = A([internalModelFor(model1), internalModelFor(model2)]); + let snapshot = recordArray._createSnapshot(); + let [snapshot1, snapshot2] = snapshot.snapshots(); - let recordArray = RecordArray.create({ - content, + assert.equal( + snapshot1.id, + model1.id, + 'record array snapshot should contain the first internalModel.createSnapshot result' + ); + assert.equal( + snapshot2.id, + model2.id, + 'record array snapshot should contain the second internalModel.createSnapshot result' + ); }); - let snapshot = recordArray._createSnapshot(); - let snapshots = snapshot.snapshots(); + test('#destroy second', async function(assert) { + let didUnregisterRecordArray = 0; + let didDissociatieFromOwnRecords = 0; - assert.deepEqual( - snapshots, - [model1, model2], - 'record array snapshot should contain the internalModel.createSnapshot result' - ); - }); + this.owner.register('model:tag', Tag); + let store = this.owner.lookup('service:store'); - test('#destroy', function(assert) { - let didUnregisterRecordArray = 0; - let didDissociatieFromOwnRecords = 0; - let model1 = {}; - let internalModel1 = internalModelFor(model1); + let model1 = { + id: 1, + type: 'tag', + }; + let record = store.push({ + data: model1, + }); - // TODO: this will be removed once we fix ownership related memory leaks. - internalModel1.__recordArrays = { - delete(array) { + // TODO: this will be removed once we fix ownership related memory leaks. + const set = new Set(); + set.delete = array => { didDissociatieFromOwnRecords++; assert.equal(array, recordArray); - }, - }; - // end TODO: - - let recordArray = RecordArray.create({ - content: A([internalModel1]), - manager: { - unregisterRecordArray(_recordArray) { - didUnregisterRecordArray++; - assert.equal(recordArray, _recordArray); + }; + // end TODO: + + let recordArray = RecordArray.create({ + content: A([recordIdentifierFor(record)]), + manager: { + getRecordArraysForIdentifier() { + return set; + }, + unregisterRecordArray(_recordArray) { + didUnregisterRecordArray++; + assert.equal(recordArray, _recordArray); + }, }, - }, - }); + store, + }); - assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); - assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); + assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); + assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); - run(() => { assert.equal(get(recordArray, 'length'), 1, 'before destroy, length should be 1'); assert.equal(didUnregisterRecordArray, 0, 'before destroy, we should not yet have unregisterd the record array'); assert.equal( @@ -414,14 +489,442 @@ module('unit/record-arrays/record-array - DS.RecordArray', function() { 'before destroy, we should not yet have dissociated from own record array' ); recordArray.destroy(); + await settled(); + + assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); + assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); + recordArray.destroy(); + + assert.strictEqual(get(recordArray, 'content'), null); + assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); + assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); + }); + }); +} else { + module('unit/record-arrays/record-array - DS.RecordArray', function() { + test('default initial state', function(assert) { + let recordArray = RecordArray.create({ modelName: 'recordType' }); + + assert.equal(get(recordArray, 'isLoaded'), false); + assert.equal(get(recordArray, 'isUpdating'), false); + assert.equal(get(recordArray, 'modelName'), 'recordType'); + assert.strictEqual(get(recordArray, 'content'), null); + assert.strictEqual(get(recordArray, 'store'), null); }); - assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); - assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); - recordArray.destroy(); + test('custom initial state', function(assert) { + let content = A(); + let store = {}; + let recordArray = RecordArray.create({ + modelName: 'apple', + isLoaded: true, + isUpdating: true, + content, + store, + }); + assert.equal(get(recordArray, 'isLoaded'), true); + assert.equal(get(recordArray, 'isUpdating'), false); // cannot set as default value: + assert.equal(get(recordArray, 'modelName'), 'apple'); + assert.equal(get(recordArray, 'content'), content); + assert.equal(get(recordArray, 'store'), store); + }); + + test('#replace() throws error', function(assert) { + let recordArray = RecordArray.create({ modelName: 'recordType' }); - assert.strictEqual(get(recordArray, 'content'), null); - assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); - assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); + assert.throws( + () => { + recordArray.replace(); + }, + Error( + 'The result of a server query (for all recordType types) is immutable. To modify contents, use toArray()' + ), + 'throws error' + ); + }); + + test('#objectAtContent', function(assert) { + let content = A([ + { + getRecord() { + return 'foo'; + }, + }, + { + getRecord() { + return 'bar'; + }, + }, + { + getRecord() { + return 'baz'; + }, + }, + ]); + + let recordArray = RecordArray.create({ + modelName: 'recordType', + content, + }); + + assert.equal(get(recordArray, 'length'), 3); + assert.equal(recordArray.objectAtContent(0), 'foo'); + assert.equal(recordArray.objectAtContent(1), 'bar'); + assert.equal(recordArray.objectAtContent(2), 'baz'); + assert.strictEqual(recordArray.objectAtContent(3), undefined); + }); + + test('#update', function(assert) { + let findAllCalled = 0; + let deferred = RSVP.defer(); + + const store = { + findAll(modelName, options) { + findAllCalled++; + assert.equal(modelName, 'recordType'); + assert.equal(options.reload, true, 'options should contain reload: true'); + return deferred.promise; + }, + }; + + let recordArray = RecordArray.create({ + modelName: 'recordType', + store, + }); + + assert.equal(get(recordArray, 'isUpdating'), false, 'should not yet be updating'); + + assert.equal(findAllCalled, 0); + + let updateResult = recordArray.update(); + + assert.equal(findAllCalled, 1); + + deferred.resolve('return value'); + + assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); + + return updateResult.then(result => { + assert.equal(result, 'return value'); + assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); + }); + }); + + test('#update while updating', function(assert) { + let findAllCalled = 0; + let deferred = RSVP.defer(); + const store = { + findAll(modelName, options) { + findAllCalled++; + return deferred.promise; + }, + }; + + let recordArray = RecordArray.create({ + modelName: { modelName: 'recordType' }, + store, + }); + + assert.equal(get(recordArray, 'isUpdating'), false, 'should not be updating'); + assert.equal(findAllCalled, 0); + + let updateResult1 = recordArray.update(); + + assert.equal(findAllCalled, 1); + + let updateResult2 = recordArray.update(); + + assert.equal(findAllCalled, 1); + + assert.equal(updateResult1, updateResult2); + + deferred.resolve('return value'); + + assert.equal(get(recordArray, 'isUpdating'), true, 'should be updating'); + + return updateResult1.then(result => { + assert.equal(result, 'return value'); + assert.equal(get(recordArray, 'isUpdating'), false, 'should no longer be updating'); + }); + }); + + test('#_pushInternalModels', function(assert) { + let content = A(); + let recordArray = RecordArray.create({ + content, + }); + + let model1 = { + id: 1, + getRecord() { + return 'model-1'; + }, + }; + let model2 = { + id: 2, + getRecord() { + return 'model-2'; + }, + }; + let model3 = { + id: 3, + getRecord() { + return 'model-3'; + }, + }; + + assert.equal(recordArray._pushInternalModels([model1]), undefined, '_pushInternalModels has no return value'); + assert.deepEqual(content, [model1], 'now contains model1'); + + recordArray._pushInternalModels([model1]); + assert.deepEqual( + content, + [model1, model1], + 'allows duplicates, because record-array-manager via internalModel._recordArrays ensures no duplicates, this layer should not double check' + ); + + recordArray._removeInternalModels([model1]); + recordArray._pushInternalModels([model1]); + + // can add multiple models at once + recordArray._pushInternalModels([model2, model3]); + assert.deepEqual(content, [model1, model2, model3], 'now contains model1, model2, model3'); + }); + + test('#_removeInternalModels', function(assert) { + let content = A(); + let recordArray = RecordArray.create({ + content, + }); + + let model1 = { + id: 1, + getRecord() { + return 'model-1'; + }, + }; + let model2 = { + id: 2, + getRecord() { + return 'model-2'; + }, + }; + let model3 = { + id: 3, + getRecord() { + return 'model-3'; + }, + }; + + assert.equal(content.length, 0); + assert.equal(recordArray._removeInternalModels([model1]), undefined, '_removeInternalModels has no return value'); + assert.deepEqual(content, [], 'now contains no models'); + + recordArray._pushInternalModels([model1, model2]); + + assert.deepEqual(content, [model1, model2], 'now contains model1, model2,'); + assert.equal(recordArray._removeInternalModels([model1]), undefined, '_removeInternalModels has no return value'); + assert.deepEqual(content, [model2], 'now only contains model2'); + assert.equal(recordArray._removeInternalModels([model2]), undefined, '_removeInternalModels has no return value'); + assert.deepEqual(content, [], 'now contains no models'); + + recordArray._pushInternalModels([model1, model2, model3]); + + assert.equal( + recordArray._removeInternalModels([model1, model3]), + undefined, + '_removeInternalModels has no return value' + ); + + assert.deepEqual(content, [model2], 'now contains model2'); + assert.equal(recordArray._removeInternalModels([model2]), undefined, '_removeInternalModels has no return value'); + assert.deepEqual(content, [], 'now contains no models'); + }); + + class FakeInternalModel { + constructor(record) { + this._record = record; + this.__recordArrays = null; + } + + get _recordArrays() { + return this.__recordArrays; + } + + getRecord() { + return this._record; + } + + createSnapshot() { + return this._record; + } + } + + function internalModelFor(record) { + return new FakeInternalModel(record); + } + + test('#save', function(assert) { + let model1 = { + save() { + model1Saved++; + return this; + }, + }; + let model2 = { + save() { + model2Saved++; + return this; + }, + }; + let content = A([internalModelFor(model1), internalModelFor(model2)]); + + let recordArray = RecordArray.create({ + content, + }); + + let model1Saved = 0; + let model2Saved = 0; + + assert.equal(model1Saved, 0); + assert.equal(model2Saved, 0); + + let result = recordArray.save(); + + assert.equal(model1Saved, 1); + assert.equal(model2Saved, 1); + + return result.then(result => { + assert.equal(result, result, 'save promise should fulfill with the original recordArray'); + }); + }); + + test('#destroy', function(assert) { + let didUnregisterRecordArray = 0; + let didDissociatieFromOwnRecords = 0; + let model1 = {}; + let internalModel1 = internalModelFor(model1); + + // TODO: this will be removed once we fix ownership related memory leaks. + internalModel1.__recordArrays = { + delete(array) { + didDissociatieFromOwnRecords++; + assert.equal(array, recordArray); + }, + }; + // end TODO: + + let recordArray = RecordArray.create({ + content: A([internalModel1]), + manager: { + unregisterRecordArray(_recordArray) { + didUnregisterRecordArray++; + assert.equal(recordArray, _recordArray); + }, + }, + }); + + assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); + assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); + + run(() => { + assert.equal(get(recordArray, 'length'), 1, 'before destroy, length should be 1'); + assert.equal( + didUnregisterRecordArray, + 0, + 'before destroy, we should not yet have unregisterd the record array' + ); + assert.equal( + didDissociatieFromOwnRecords, + 0, + 'before destroy, we should not yet have dissociated from own record array' + ); + recordArray.destroy(); + }); + + assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); + assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); + + assert.strictEqual(get(recordArray, 'content'), null); + assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); + assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); + }); + + test('#_createSnapshot', function(assert) { + let model1 = { + id: 1, + }; + + let model2 = { + id: 2, + }; + + let content = A([internalModelFor(model1), internalModelFor(model2)]); + + let recordArray = RecordArray.create({ + content, + }); + + let snapshot = recordArray._createSnapshot(); + let snapshots = snapshot.snapshots(); + + assert.deepEqual( + snapshots, + [model1, model2], + 'record array snapshot should contain the internalModel.createSnapshot result' + ); + }); + + test('#destroy', function(assert) { + let didUnregisterRecordArray = 0; + let didDissociatieFromOwnRecords = 0; + let model1 = {}; + let internalModel1 = internalModelFor(model1); + + // TODO: this will be removed once we fix ownership related memory leaks. + internalModel1.__recordArrays = { + delete(array) { + didDissociatieFromOwnRecords++; + assert.equal(array, recordArray); + }, + }; + // end TODO: + + let recordArray = RecordArray.create({ + content: A([internalModel1]), + manager: { + unregisterRecordArray(_recordArray) { + didUnregisterRecordArray++; + assert.equal(recordArray, _recordArray); + }, + }, + }); + + assert.equal(get(recordArray, 'isDestroyed'), false, 'should not be destroyed'); + assert.equal(get(recordArray, 'isDestroying'), false, 'should not be destroying'); + + run(() => { + assert.equal(get(recordArray, 'length'), 1, 'before destroy, length should be 1'); + assert.equal( + didUnregisterRecordArray, + 0, + 'before destroy, we should not yet have unregisterd the record array' + ); + assert.equal( + didDissociatieFromOwnRecords, + 0, + 'before destroy, we should not yet have dissociated from own record array' + ); + recordArray.destroy(); + }); + + assert.equal(didUnregisterRecordArray, 1, 'after destroy we should have unregistered the record array'); + assert.equal(didDissociatieFromOwnRecords, 1, 'after destroy, we should have dissociated from own record array'); + recordArray.destroy(); + + assert.strictEqual(get(recordArray, 'content'), null); + assert.equal(get(recordArray, 'length'), 0, 'after destroy we should have no length'); + assert.equal(get(recordArray, 'isDestroyed'), true, 'should be destroyed'); + }); }); -}); +} diff --git a/packages/adapter/.ember-cli b/packages/adapter/.ember-cli deleted file mode 100644 index ee64cfed2a8..00000000000 --- a/packages/adapter/.ember-cli +++ /dev/null @@ -1,9 +0,0 @@ -{ - /** - Ember CLI sends analytics information by default. The data is completely - anonymous, but there are times when you might want to disable this behavior. - - Setting `disableAnalytics` to true will prevent any data from being sent. - */ - "disableAnalytics": false -} diff --git a/packages/canary-features/addon/default-features.ts b/packages/canary-features/addon/default-features.ts index 2ce11b9837f..fa3edd494a8 100644 --- a/packages/canary-features/addon/default-features.ts +++ b/packages/canary-features/addon/default-features.ts @@ -177,4 +177,6 @@ export default { REQUEST_SERVICE: null, CUSTOM_MODEL_CLASS: null, FULL_LINKS_ON_RELATIONSHIPS: true, + RECORD_ARRAY_MANAGER_IDENTIFIERS: null, + REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT: null, }; diff --git a/packages/canary-features/addon/index.ts b/packages/canary-features/addon/index.ts index d0b4b6e6915..172e245e02e 100644 --- a/packages/canary-features/addon/index.ts +++ b/packages/canary-features/addon/index.ts @@ -8,11 +8,13 @@ import { assign } from '@ember/polyfills'; import DEFAULT_FEATURES from './default-features'; +type FeatureList = { + [key in keyof typeof DEFAULT_FEATURES]: boolean | null; +}; + interface ConfigEnv { ENABLE_OPTIONAL_FEATURES?: boolean; - FEATURES?: { - [key in keyof typeof DEFAULT_FEATURES]: boolean | null; - }; + FEATURES?: FeatureList; } declare global { @@ -28,7 +30,7 @@ function featureValue(value: boolean | null): boolean | null { return value; } -export const FEATURES = assign({}, DEFAULT_FEATURES, ENV.FEATURES); +export const FEATURES: FeatureList = assign({}, DEFAULT_FEATURES, ENV.FEATURES); export const SAMPLE_FEATURE_FLAG = featureValue(FEATURES.SAMPLE_FEATURE_FLAG); export const RECORD_DATA_ERRORS = featureValue(FEATURES.RECORD_DATA_ERRORS); export const RECORD_DATA_STATE = featureValue(FEATURES.RECORD_DATA_STATE); @@ -36,3 +38,7 @@ export const REQUEST_SERVICE = featureValue(FEATURES.REQUEST_SERVICE); export const IDENTIFIERS = featureValue(FEATURES.IDENTIFIERS); export const CUSTOM_MODEL_CLASS = featureValue(FEATURES.CUSTOM_MODEL_CLASS); export const FULL_LINKS_ON_RELATIONSHIPS = featureValue(FEATURES.FULL_LINKS_ON_RELATIONSHIPS); +export const RECORD_ARRAY_MANAGER_IDENTIFIERS = featureValue(FEATURES.RECORD_ARRAY_MANAGER_IDENTIFIERS); +export const REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT = featureValue( + FEATURES.REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT +); diff --git a/packages/store/addon/-private/system/core-store.ts b/packages/store/addon/-private/system/core-store.ts index 73804bbef81..f281cd17d7c 100644 --- a/packages/store/addon/-private/system/core-store.ts +++ b/packages/store/addon/-private/system/core-store.ts @@ -20,6 +20,7 @@ import { all, default as RSVP, defer, Promise, resolve } from 'rsvp'; import { CUSTOM_MODEL_CLASS, + RECORD_ARRAY_MANAGER_IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE, REQUEST_SERVICE, @@ -225,7 +226,7 @@ abstract class CoreStore extends Service { * EmberData specific backburner instance */ public _backburner: Backburner = edBackburner; - private recordArrayManager: RecordArrayManager = new RecordArrayManager({ store: this }); + public recordArrayManager: RecordArrayManager = new RecordArrayManager({ store: this }); public _notificationManager: NotificationManager; private _adapterCache = Object.create(null); @@ -2639,8 +2640,8 @@ abstract class CoreStore extends Service { const isUpdate = internalModel.currentState.isEmpty === false && !isLoading; // exclude store.push (root.empty) case + let identifier = internalModel.identifier; if (isUpdate || isLoading) { - let identifier = internalModel.identifier; let updatedIdentifier = identifierCacheFor(this).updateRecordIdentifier(identifier, data); if (updatedIdentifier !== identifier) { @@ -2657,7 +2658,11 @@ abstract class CoreStore extends Service { internalModel.setupData(data); if (!isUpdate) { - this.recordArrayManager.recordDidChange(internalModel); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + this.recordArrayManager.recordDidChange(identifier); + } else { + this.recordArrayManager.recordDidChange(internalModel); + } } return internalModel; diff --git a/packages/store/addon/-private/system/internal-model-map.ts b/packages/store/addon/-private/system/internal-model-map.ts index 8f1d9f99249..f0d1ce9ea69 100644 --- a/packages/store/addon/-private/system/internal-model-map.ts +++ b/packages/store/addon/-private/system/internal-model-map.ts @@ -2,6 +2,8 @@ import { assert } from '@ember/debug'; import InternalModel from './model/internal-model'; +type StableRecordIdentifier = import('../ts-interfaces/identifier').StableRecordIdentifier; + type ConfidentDict = import('../ts-interfaces/utils').ConfidentDict; /** @@ -42,6 +44,10 @@ export default class InternalModelMap { return this._models.length; } + get recordIdentifiers(): StableRecordIdentifier[] { + return this._models.map(m => m.identifier); + } + set(id: string, internalModel: InternalModel): void { assert(`You cannot index an internalModel by an empty id'`, typeof id === 'string' && id.length > 0); assert( diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 0b3867d5295..e124e7515d4 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -13,8 +13,10 @@ import RSVP, { Promise } from 'rsvp'; import { CUSTOM_MODEL_CLASS, FULL_LINKS_ON_RELATIONSHIPS, + RECORD_ARRAY_MANAGER_IDENTIFIERS, RECORD_DATA_ERRORS, RECORD_DATA_STATE, + REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT, REQUEST_SERVICE, } from '@ember-data/canary-features'; import { HAS_MODEL_PACKAGE } from '@ember-data/private-build-infra'; @@ -22,6 +24,7 @@ import { HAS_MODEL_PACKAGE } from '@ember-data/private-build-infra'; import { identifierCacheFor } from '../../identifiers/cache'; import coerceId from '../coerce-id'; import { errorsHashToArray } from '../errors-utils'; +import { recordArraysForIdentifier } from '../record-array-manager'; import recordDataFor from '../record-data-for'; import { BelongsToReference, HasManyReference, RecordReference } from '../references'; import Snapshot from '../snapshot'; @@ -249,13 +252,6 @@ export default class InternalModel { this.__recordData = newValue; } - get _recordArrays(): Set { - if (this.__recordArrays === null) { - this.__recordArrays = new Set(); - } - return this.__recordArrays; - } - get references() { if (this._references === null) { this._references = Object.create(null); @@ -500,9 +496,9 @@ export default class InternalModel { } // move to an empty never-loaded state + this.updateRecordArrays(); this._recordData.unloadRecord(); this.resetRecord(); - this.updateRecordArrays(); } deleteRecord() { @@ -1363,8 +1359,11 @@ export default class InternalModel { @private */ updateRecordArrays() { - // @ts-ignore: Store is untyped and typescript does not detect instance props set in `init` - this.store.recordArrayManager.recordDidChange(this); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + this.store.recordArrayManager.recordDidChange(this.identifier); + } else { + this.store.recordArrayManager.recordDidChange(this); + } } setId(id: string) { @@ -1580,6 +1579,29 @@ export default class InternalModel { } } +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + // in production code, this is only accesssed in `record-array-manager` + // if LEGACY_COMPAT is also on + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { + Object.defineProperty(InternalModel.prototype, '_recordArrays', { + get() { + return recordArraysForIdentifier(this.identifier); + }, + }); + } +} else { + // TODO investigate removing this property since it will only be used in tests + // once RECORD_ARRAY_MANAGER_IDENTIFIERS is turned on + Object.defineProperty(InternalModel.prototype, '_recordArrays', { + get() { + if (this.__recordArrays === null) { + this.__recordArrays = new Set(); + } + return this.__recordArrays; + }, + }); +} + function handleCompletedRelationshipRequest(internalModel, key, relationship, value, error) { delete internalModel._relationshipPromisesCache[key]; relationship.setShouldForceReload(false); diff --git a/packages/store/addon/-private/system/record-array-manager.js b/packages/store/addon/-private/system/record-array-manager.js index 394431d6ef8..3645874d2c2 100644 --- a/packages/store/addon/-private/system/record-array-manager.js +++ b/packages/store/addon/-private/system/record-array-manager.js @@ -8,347 +8,815 @@ import { get, set } from '@ember/object'; import { assign } from '@ember/polyfills'; import { run as emberRunloop } from '@ember/runloop'; +import { + RECORD_ARRAY_MANAGER_IDENTIFIERS, + REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT, +} from '@ember-data/canary-features'; + +import isStableIdentifier from '../identifiers/is-stable-identifier'; import { AdapterPopulatedRecordArray, RecordArray } from './record-arrays'; import { internalModelFactoryFor } from './store/internal-model-factory'; +const RecordArraysCache = new WeakMap(); const emberRun = emberRunloop.backburner; +let RecordArrayManager; +// TODO: Remove when RECORD_ARRAY_MANAGER_IDENTIFIERS is turned on +let associateWithRecordArray; + +export function recordArraysForIdentifier(identifierOrInternalModel) { + if (RecordArraysCache.has(identifierOrInternalModel)) { + // return existing Set if exists + return RecordArraysCache.get(identifierOrInternalModel); + } + + // returns workable Set instance + RecordArraysCache.set(identifierOrInternalModel, new Set()); + return RecordArraysCache.get(identifierOrInternalModel); +} + /** @class RecordArrayManager @private */ -export default class RecordArrayManager { - constructor(options) { - this.store = options.store; - this.isDestroying = false; - this.isDestroyed = false; - this._liveRecordArrays = Object.create(null); - this._pending = Object.create(null); - this._adapterPopulatedRecordArrays = []; - } +if (!RECORD_ARRAY_MANAGER_IDENTIFIERS) { + RecordArrayManager = class LegacyRecordArrayManager { + constructor(options) { + this.store = options.store; + this.isDestroying = false; + this.isDestroyed = false; + this._liveRecordArrays = Object.create(null); + this._pending = Object.create(null); + this._adapterPopulatedRecordArrays = []; + } + + /** + @method recordDidChange + @internal + */ + recordDidChange(internalModel) { + let modelName = internalModel.modelName; + + if (internalModel._pendingRecordArrayManagerFlush) { + return; + } + + internalModel._pendingRecordArrayManagerFlush = true; - recordDidChange(internalModel) { - let modelName = internalModel.modelName; + let pending = this._pending; + let models = (pending[modelName] = pending[modelName] || []); + if (models.push(internalModel) !== 1) { + return; + } - if (internalModel._pendingRecordArrayManagerFlush) { - return; + emberRun.schedule('actions', this, this._flush); } - internalModel._pendingRecordArrayManagerFlush = true; + _flushPendingInternalModelsForModelName(modelName, internalModels) { + let modelsToRemove = []; + + for (let j = 0; j < internalModels.length; j++) { + let internalModel = internalModels[j]; + // mark internalModels, so they can once again be processed by the + // recordArrayManager + internalModel._pendingRecordArrayManagerFlush = false; + // build up a set of models to ensure we have purged correctly; + if (internalModel.isHiddenFromRecordArrays()) { + modelsToRemove.push(internalModel); + } + } + + let array = this._liveRecordArrays[modelName]; + if (array) { + // TODO: skip if it only changed + // process liveRecordArrays + updateInternalModelsForLiveRecordArray(array, internalModels); + } - let pending = this._pending; - let models = (pending[modelName] = pending[modelName] || []); - if (models.push(internalModel) !== 1) { - return; + // process adapterPopulatedRecordArrays + if (modelsToRemove.length > 0) { + removeInternalModelsFromAdapterPopulatedRecordArrays(modelsToRemove); + } } - emberRun.schedule('actions', this, this._flush); - } + _flush() { + let pending = this._pending; + this._pending = Object.create(null); - _flushPendingInternalModelsForModelName(modelName, internalModels) { - let modelsToRemove = []; + for (let modelName in pending) { + this._flushPendingInternalModelsForModelName(modelName, pending[modelName]); + } + } - for (let j = 0; j < internalModels.length; j++) { - let internalModel = internalModels[j]; - // mark internalModels, so they can once again be processed by the - // recordArrayManager - internalModel._pendingRecordArrayManagerFlush = false; - // build up a set of models to ensure we have purged correctly; - if (internalModel.isHiddenFromRecordArrays()) { - modelsToRemove.push(internalModel); + _syncLiveRecordArray(array, modelName) { + assert( + `recordArrayManger.syncLiveRecordArray expects modelName not modelClass as the second param`, + typeof modelName === 'string' + ); + let pending = this._pending[modelName]; + let hasPendingChanges = Array.isArray(pending); + let hasNoPotentialDeletions = !hasPendingChanges || pending.length === 0; + let map = internalModelFactoryFor(this.store).modelMapFor(modelName); + let hasNoInsertionsOrRemovals = get(map, 'length') === get(array, 'length'); + + /* + Ideally the recordArrayManager has knowledge of the changes to be applied to + liveRecordArrays, and is capable of strategically flushing those changes and applying + small diffs if desired. However, until we've refactored recordArrayManager, this dirty + check prevents us from unnecessarily wiping out live record arrays returned by peekAll. + */ + if (hasNoPotentialDeletions && hasNoInsertionsOrRemovals) { + return; + } + + if (hasPendingChanges) { + this._flushPendingInternalModelsForModelName(modelName, pending); + delete this._pending[modelName]; + } + + let internalModels = this._visibleInternalModelsByType(modelName); + let modelsToAdd = []; + for (let i = 0; i < internalModels.length; i++) { + let internalModel = internalModels[i]; + let recordArrays = internalModel._recordArrays; + if (recordArrays.has(array) === false) { + recordArrays.add(array); + modelsToAdd.push(internalModel); + } + } + + if (modelsToAdd.length) { + array._pushInternalModels(modelsToAdd); } } - let array = this._liveRecordArrays[modelName]; - if (array) { - // TODO: skip if it only changed - // process liveRecordArrays - updateLiveRecordArray(array, internalModels); + _didUpdateAll(modelName) { + let recordArray = this._liveRecordArrays[modelName]; + if (recordArray) { + set(recordArray, 'isUpdating', false); + } } - // process adapterPopulatedRecordArrays - if (modelsToRemove.length > 0) { - removeFromAdapterPopulatedRecordArrays(modelsToRemove); + /** + Get the `RecordArray` for a modelName, which contains all loaded records of + given modelName. + + @method liveRecordArrayFor + @param {String} modelName + @return {RecordArray} + */ + liveRecordArrayFor(modelName) { + assert( + `recordArrayManger.liveRecordArrayFor expects modelName not modelClass as the param`, + typeof modelName === 'string' + ); + + let array = this._liveRecordArrays[modelName]; + + if (array) { + // if the array already exists, synchronize + this._syncLiveRecordArray(array, modelName); + } else { + // if the array is being newly created merely create it with its initial + // content already set. This prevents unneeded change events. + let internalModels = this._visibleInternalModelsByType(modelName); + array = this.createRecordArray(modelName, internalModels); + this._liveRecordArrays[modelName] = array; + } + + return array; } - } - _flush() { - let pending = this._pending; - this._pending = Object.create(null); + _visibleInternalModelsByType(modelName) { + let all = internalModelFactoryFor(this.store).modelMapFor(modelName)._models; + let visible = []; + for (let i = 0; i < all.length; i++) { + let model = all[i]; + if (model.isHiddenFromRecordArrays() === false) { + visible.push(model); + } + } + return visible; + } + + /** + Create a `RecordArray` for a modelName. + + @method createRecordArray + @param {String} modelName + @param {Array} [internalModels] + @return {RecordArray} + */ + createRecordArray(modelName, internalModels) { + assert( + `recordArrayManger.createRecordArray expects modelName not modelClass as the param`, + typeof modelName === 'string' + ); + + let array = RecordArray.create({ + modelName, + content: A(internalModels || []), + store: this.store, + isLoaded: true, + manager: this, + }); - for (let modelName in pending) { - this._flushPendingInternalModelsForModelName(modelName, pending[modelName]); + if (Array.isArray(internalModels)) { + associateWithRecordArray(internalModels, array); + } + + return array; } - } - _syncLiveRecordArray(array, modelName) { - assert( - `recordArrayManger.syncLiveRecordArray expects modelName not modelClass as the second param`, - typeof modelName === 'string' - ); - let pending = this._pending[modelName]; - let hasPendingChanges = Array.isArray(pending); - let hasNoPotentialDeletions = !hasPendingChanges || pending.length === 0; - let map = internalModelFactoryFor(this.store).modelMapFor(modelName); - let hasNoInsertionsOrRemovals = get(map, 'length') === get(array, 'length'); - - /* - Ideally the recordArrayManager has knowledge of the changes to be applied to - liveRecordArrays, and is capable of strategically flushing those changes and applying - small diffs if desired. However, until we've refactored recordArrayManager, this dirty - check prevents us from unnecessarily wiping out live record arrays returned by peekAll. - */ - if (hasNoPotentialDeletions && hasNoInsertionsOrRemovals) { - return; - } - - if (hasPendingChanges) { - this._flushPendingInternalModelsForModelName(modelName, pending); - delete this._pending[modelName]; - } - - let internalModels = this._visibleInternalModelsByType(modelName); + /** + Create a `AdapterPopulatedRecordArray` for a modelName with given query. + + @method createAdapterPopulatedRecordArray + @param {String} modelName + @param {Object} query + @param {Array} internalModels + @param {Object} payload + @return {AdapterPopulatedRecordArray} + */ + createAdapterPopulatedRecordArray(modelName, query, internalModels, payload) { + assert( + `recordArrayManger.createAdapterPopulatedRecordArray expects modelName not modelClass as the first param, received ${modelName}`, + typeof modelName === 'string' + ); + + let array; + if (Array.isArray(internalModels)) { + array = AdapterPopulatedRecordArray.create({ + modelName, + query: query, + content: A(internalModels), + store: this.store, + manager: this, + isLoaded: true, + isUpdating: false, + meta: assign({}, payload.meta), + links: assign({}, payload.links), + }); + + associateWithRecordArray(internalModels, array); + } else { + array = AdapterPopulatedRecordArray.create({ + modelName, + query: query, + content: A(), + store: this.store, + manager: this, + }); + } + + this._adapterPopulatedRecordArrays.push(array); + + return array; + } + + /** + Unregister a RecordArray. + So manager will not update this array. + + @method unregisterRecordArray + @param {RecordArray} array + */ + unregisterRecordArray(array) { + let modelName = array.modelName; + + // remove from adapter populated record array + let removedFromAdapterPopulated = removeEntry(this._adapterPopulatedRecordArrays, array); + + if (!removedFromAdapterPopulated) { + let liveRecordArrayForType = this._liveRecordArrays[modelName]; + // unregister live record array + if (liveRecordArrayForType) { + if (array === liveRecordArrayForType) { + delete this._liveRecordArrays[modelName]; + } + } + } + } + + _associateWithRecordArray(internalModels, array) { + associateWithRecordArray(internalModels, array); + } + + willDestroy() { + Object.keys(this._liveRecordArrays).forEach(modelName => this._liveRecordArrays[modelName].destroy()); + this._adapterPopulatedRecordArrays.forEach(destroyEntry); + this.isDestroyed = true; + } + + destroy() { + this.isDestroying = true; + emberRun.schedule('actions', this, this.willDestroy); + } + }; + + const destroyEntry = function destroyEntry(entry) { + entry.destroy(); + }; + + const removeEntry = function removeEntry(array, item) { + let index = array.indexOf(item); + + if (index !== -1) { + array.splice(index, 1); + return true; + } + + return false; + }; + + const updateInternalModelsForLiveRecordArray = function updateInternalModelsForLiveRecordArray( + array, + internalModels + ) { let modelsToAdd = []; + let modelsToRemove = []; + for (let i = 0; i < internalModels.length; i++) { let internalModel = internalModels[i]; + let isDeleted = internalModel.isHiddenFromRecordArrays(); let recordArrays = internalModel._recordArrays; - if (recordArrays.has(array) === false) { - recordArrays.add(array); - modelsToAdd.push(internalModel); + + if (!isDeleted && !internalModel.isEmpty()) { + if (!recordArrays.has(array)) { + modelsToAdd.push(internalModel); + recordArrays.add(array); + } + } + + if (isDeleted) { + modelsToRemove.push(internalModel); + recordArrays.delete(array); } } - if (modelsToAdd.length) { + if (modelsToAdd.length > 0) { array._pushInternalModels(modelsToAdd); } - } + if (modelsToRemove.length > 0) { + array._removeInternalModels(modelsToRemove); + } + }; - _didUpdateAll(modelName) { - let recordArray = this._liveRecordArrays[modelName]; - if (recordArray) { - set(recordArray, 'isUpdating', false); + const removeInternalModelsFromAdapterPopulatedRecordArrays = function removeInternalModelsFromAdapterPopulatedRecordArrays( + internalModels + ) { + for (let i = 0; i < internalModels.length; i++) { + removeInternalModelFromAll(internalModels[i]); } - } + }; - /** - Get the `RecordArray` for a modelName, which contains all loaded records of - given modelName. - - @method liveRecordArrayFor - @param {String} modelName - @return {RecordArray} - */ - liveRecordArrayFor(modelName) { - assert( - `recordArrayManger.liveRecordArrayFor expects modelName not modelClass as the param`, - typeof modelName === 'string' - ); - - let array = this._liveRecordArrays[modelName]; - - if (array) { - // if the array already exists, synchronize - this._syncLiveRecordArray(array, modelName); - } else { - // if the array is being newly created merely create it with its initial - // content already set. This prevents unneeded change events. - let internalModels = this._visibleInternalModelsByType(modelName); - array = this.createRecordArray(modelName, internalModels); - this._liveRecordArrays[modelName] = array; + const removeInternalModelFromAll = function removeInternalModelFromAll(internalModel) { + const recordArrays = internalModel._recordArrays; + + recordArrays.forEach(function(recordArray) { + recordArray._removeInternalModels([internalModel]); + }); + + recordArrays.clear(); + }; + + associateWithRecordArray = function associateWithRecordArray(internalModels, array) { + for (let i = 0, l = internalModels.length; i < l; i++) { + let internalModel = internalModels[i]; + internalModel._recordArrays.add(array); + } + }; +} else { + const emberRun = emberRunloop.backburner; + const pendingForIdentifier = new Set([]); + const IMDematerializing = new WeakMap(); + + const getIdentifier = function getIdentifier(identifierOrInternalModel) { + let i = identifierOrInternalModel; + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT && !isStableIdentifier(identifierOrInternalModel)) { + // identifier may actually be an internalModel + // but during materialization we will get an identifier that + // has already been removed from the identifiers cache yet + // so it will not behave as if stable. This is a bug we should fix. + i = identifierOrInternalModel.identifier || i; } - return array; - } + return i; + }; - _visibleInternalModelsByType(modelName) { - let all = internalModelFactoryFor(this.store).modelMapFor(modelName)._models; - let visible = []; - for (let i = 0; i < all.length; i++) { - let model = all[i]; - if (model.isHiddenFromRecordArrays() === false) { - visible.push(model); + // REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT only + const peekIMCache = function peekIMCache(cache, identifier) { + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { + let im = IMDematerializing.get(identifier); + if (im === undefined) { + // if not im._isDematerializing + im = cache.peek(identifier); } + + return im; } - return visible; - } - /** - Create a `RecordArray` for a modelName. - - @method createRecordArray - @param {String} modelName - @param {Array} _content (optional|private) - @return {RecordArray} - */ - createRecordArray(modelName, content) { - assert( - `recordArrayManger.createRecordArray expects modelName not modelClass as the param`, - typeof modelName === 'string' - ); - - let array = RecordArray.create({ - modelName, - content: A(content || []), - store: this.store, - isLoaded: true, - manager: this, - }); + return cache.peek(identifier); + }; + + const shouldIncludeInRecordArrays = function shouldIncludeInRecordArrays(store, identifier) { + const cache = internalModelFactoryFor(store); + const internalModel = cache.peek(identifier); - if (Array.isArray(content)) { - associateWithRecordArray(content, array); + if (internalModel === null) { + return false; + } + return !internalModel.isHiddenFromRecordArrays(); + }; + + RecordArrayManager = class IdentifiersRecordArrayManager { + constructor(options) { + this.store = options.store; + this.isDestroying = false; + this.isDestroyed = false; + this._liveRecordArrays = Object.create(null); + this._pendingIdentifiers = Object.create(null); + this._adapterPopulatedRecordArrays = []; } - return array; - } + /** + * @method getRecordArraysForIdentifier + * @public + * @param {StableIdentifier} param + * @return {RecordArray} array + */ + getRecordArraysForIdentifier(identifier) { + return recordArraysForIdentifier(identifier); + } - /** - Create a `AdapterPopulatedRecordArray` for a modelName with given query. - - @method createAdapterPopulatedRecordArray - @param {String} modelName - @param {Object} query - @return {AdapterPopulatedRecordArray} - */ - createAdapterPopulatedRecordArray(modelName, query, internalModels, payload) { - assert( - `recordArrayManger.createAdapterPopulatedRecordArray expects modelName not modelClass as the first param, received ${modelName}`, - typeof modelName === 'string' - ); - - let array; - if (Array.isArray(internalModels)) { - array = AdapterPopulatedRecordArray.create({ - modelName, - query: query, - content: A(internalModels), - store: this.store, - manager: this, - isLoaded: true, - isUpdating: false, - meta: assign({}, payload.meta), - links: assign({}, payload.links), - }); + _flushPendingIdentifiersForModelName(modelName, identifiers) { + if (this.isDestroying || this.isDestroyed) { + return; + } + let modelsToRemove = []; + + for (let j = 0; j < identifiers.length; j++) { + let i = identifiers[j]; + // mark identifiers, so they can once again be processed by the + // recordArrayManager + pendingForIdentifier.delete(i); + // build up a set of models to ensure we have purged correctly; + let isIncluded = shouldIncludeInRecordArrays(this.store, i); + if (!isIncluded) { + modelsToRemove.push(i); + } + } - associateWithRecordArray(internalModels, array); - } else { - array = AdapterPopulatedRecordArray.create({ + let array = this._liveRecordArrays[modelName]; + if (array) { + // TODO: skip if it only changed + // process liveRecordArrays + updateLiveRecordArray(this.store, array, identifiers); + } + + // process adapterPopulatedRecordArrays + if (modelsToRemove.length > 0) { + removeFromAdapterPopulatedRecordArrays(this.store, modelsToRemove); + } + } + + _flush() { + let pending = this._pendingIdentifiers; + this._pendingIdentifiers = Object.create(null); + + for (let modelName in pending) { + this._flushPendingIdentifiersForModelName(modelName, pending[modelName]); + } + } + + _syncLiveRecordArray(array, modelName) { + assert( + `recordArrayManger.syncLiveRecordArray expects modelName not modelClass as the second param`, + typeof modelName === 'string' + ); + let pending = this._pendingIdentifiers[modelName]; + let hasPendingChanges = Array.isArray(pending); + let hasNoPotentialDeletions = !hasPendingChanges || pending.length === 0; + let map = internalModelFactoryFor(this.store).modelMapFor(modelName); + let hasNoInsertionsOrRemovals = get(map, 'length') === get(array, 'length'); + + /* + Ideally the recordArrayManager has knowledge of the changes to be applied to + liveRecordArrays, and is capable of strategically flushing those changes and applying + small diffs if desired. However, until we've refactored recordArrayManager, this dirty + check prevents us from unnecessarily wiping out live record arrays returned by peekAll. + */ + if (hasNoPotentialDeletions && hasNoInsertionsOrRemovals) { + return; + } + + if (hasPendingChanges) { + this._flushPendingIdentifiersForModelName(modelName, pending); + delete this._pendingIdentifiers[modelName]; + } + + let identifiers = this._visibleIdentifiersByType(modelName); + let modelsToAdd = []; + for (let i = 0; i < identifiers.length; i++) { + let identifier = identifiers[i]; + let recordArrays = recordArraysForIdentifier(identifier); + if (recordArrays.has(array) === false) { + recordArrays.add(array); + modelsToAdd.push(identifier); + } + } + + if (modelsToAdd.length) { + array._pushIdentifiers(modelsToAdd); + } + } + + _didUpdateAll(modelName) { + let recordArray = this._liveRecordArrays[modelName]; + if (recordArray) { + set(recordArray, 'isUpdating', false); + } + } + + /** + Get the `RecordArray` for a modelName, which contains all loaded records of + given modelName. + + @method liveRecordArrayFor + @param {String} modelName + @return {RecordArray} + */ + liveRecordArrayFor(modelName) { + assert( + `recordArrayManger.liveRecordArrayFor expects modelName not modelClass as the param`, + typeof modelName === 'string' + ); + + let array = this._liveRecordArrays[modelName]; + + if (array) { + // if the array already exists, synchronize + this._syncLiveRecordArray(array, modelName); + } else { + // if the array is being newly created merely create it with its initial + // content already set. This prevents unneeded change events. + let identifiers = this._visibleIdentifiersByType(modelName); + array = this.createRecordArray(modelName, identifiers); + this._liveRecordArrays[modelName] = array; + } + + return array; + } + + _visibleIdentifiersByType(modelName) { + let all = internalModelFactoryFor(this.store).modelMapFor(modelName).recordIdentifiers; + let visible = []; + for (let i = 0; i < all.length; i++) { + let identifier = all[i]; + let shouldInclude = shouldIncludeInRecordArrays(this.store, identifier); + + if (shouldInclude) { + visible.push(identifier); + } + } + return visible; + } + + /** + Create a `RecordArray` for a modelName. + + @method createRecordArray + @param {String} modelName + @param {Array} [identifiers] + @return {RecordArray} + */ + createRecordArray(modelName, identifiers) { + assert( + `recordArrayManger.createRecordArray expects modelName not modelClass as the param`, + typeof modelName === 'string' + ); + + let array = RecordArray.create({ modelName, - query: query, - content: A(), + content: A(identifiers || []), store: this.store, + isLoaded: true, manager: this, }); + + if (Array.isArray(identifiers)) { + this._associateWithRecordArray(identifiers, array); + } + + return array; } - this._adapterPopulatedRecordArrays.push(array); + /** + Create a `AdapterPopulatedRecordArray` for a modelName with given query. + + @method createAdapterPopulatedRecordArray + @param {String} modelName + @param {Object} query + @return {AdapterPopulatedRecordArray} + */ + createAdapterPopulatedRecordArray(modelName, query, identifiers, payload) { + assert( + `recordArrayManger.createAdapterPopulatedRecordArray expects modelName not modelClass as the first param, received ${modelName}`, + typeof modelName === 'string' + ); + + let array; + if (Array.isArray(identifiers)) { + array = AdapterPopulatedRecordArray.create({ + modelName, + query: query, + content: A(identifiers), + store: this.store, + manager: this, + isLoaded: true, + isUpdating: false, + meta: assign({}, payload.meta), + links: assign({}, payload.links), + }); + + this._associateWithRecordArray(identifiers, array); + } else { + array = AdapterPopulatedRecordArray.create({ + modelName, + query: query, + content: A(), + store: this.store, + manager: this, + }); + } - return array; - } + this._adapterPopulatedRecordArrays.push(array); + + return array; + } - /** - Unregister a RecordArray. - So manager will not update this array. - - @method unregisterRecordArray - @param {RecordArray} array - */ - unregisterRecordArray(array) { - let modelName = array.modelName; - - // remove from adapter populated record array - let removedFromAdapterPopulated = remove(this._adapterPopulatedRecordArrays, array); - - if (!removedFromAdapterPopulated) { - let liveRecordArrayForType = this._liveRecordArrays[modelName]; - // unregister live record array - if (liveRecordArrayForType) { - if (array === liveRecordArrayForType) { - delete this._liveRecordArrays[modelName]; + /** + Unregister a RecordArray. + So manager will not update this array. + + @method unregisterRecordArray + @param {RecordArray} array + */ + unregisterRecordArray(array) { + let modelName = array.modelName; + + // remove from adapter populated record array + let removedFromAdapterPopulated = removeFromArray(this._adapterPopulatedRecordArrays, array); + + if (!removedFromAdapterPopulated) { + let liveRecordArrayForType = this._liveRecordArrays[modelName]; + // unregister live record array + if (liveRecordArrayForType) { + if (array === liveRecordArrayForType) { + delete this._liveRecordArrays[modelName]; + } } } } - } - _associateWithRecordArray(internalModels, array) { - associateWithRecordArray(internalModels, array); - } + /** + * @method _associateWithRecordArray + * @private + * @param {StableIdentifier} identifiers + * @param {RecordArray} array + */ + _associateWithRecordArray(identifiers, array) { + for (let i = 0, l = identifiers.length; i < l; i++) { + let identifier = identifiers[i]; + identifier = getIdentifier(identifier); + let recordArrays = this.getRecordArraysForIdentifier(identifier); + recordArrays.add(array); + } + } - willDestroy() { - Object.keys(this._liveRecordArrays).forEach(modelName => this._liveRecordArrays[modelName].destroy()); - this._adapterPopulatedRecordArrays.forEach(destroy); - this.isDestroyed = true; - } + /** + @method recordDidChange + @internal + */ + recordDidChange(identifier) { + if (this.isDestroying || this.isDestroyed) { + return; + } + let modelName = identifier.type; + identifier = getIdentifier(identifier); + + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { + const cache = internalModelFactoryFor(this.store); + const im = peekIMCache(cache, identifier); + if (im && im._isDematerializing) { + IMDematerializing.set(identifier, im); + } + } - destroy() { - this.isDestroying = true; - emberRun.schedule('actions', this, this.willDestroy); - } -} + if (pendingForIdentifier.has(identifier)) { + return; + } -function destroy(entry) { - entry.destroy(); -} + pendingForIdentifier.add(identifier); -function remove(array, item) { - let index = array.indexOf(item); + let pending = this._pendingIdentifiers; + let models = (pending[modelName] = pending[modelName] || []); + if (models.push(identifier) !== 1) { + return; + } - if (index !== -1) { - array.splice(index, 1); - return true; - } + emberRun.schedule('actions', this, this._flush); + } - return false; -} + willDestroy() { + Object.keys(this._liveRecordArrays).forEach(modelName => this._liveRecordArrays[modelName].destroy()); + this._adapterPopulatedRecordArrays.forEach(entry => entry.destroy()); + this.isDestroyed = true; + } -function updateLiveRecordArray(array, internalModels) { - let modelsToAdd = []; - let modelsToRemove = []; + destroy() { + this.isDestroying = true; + emberRun.schedule('actions', this, this.willDestroy); + } + }; - for (let i = 0; i < internalModels.length; i++) { - let internalModel = internalModels[i]; - let isDeleted = internalModel.isHiddenFromRecordArrays(); - let recordArrays = internalModel._recordArrays; + const removeFromArray = function removeFromArray(array, item) { + let index = array.indexOf(item); - if (!isDeleted && !internalModel.isEmpty()) { - if (!recordArrays.has(array)) { - modelsToAdd.push(internalModel); - recordArrays.add(array); + if (index !== -1) { + array.splice(index, 1); + return true; + } + + return false; + }; + + const updateLiveRecordArray = function updateLiveRecordArray(store, recordArray, identifiers) { + let identifiersToAdd = []; + let identifiersToRemove = []; + + for (let i = 0; i < identifiers.length; i++) { + let identifier = identifiers[i]; + let shouldInclude = shouldIncludeInRecordArrays(store, identifier); + let recordArrays = recordArraysForIdentifier(identifier); + + if (shouldInclude) { + if (!recordArrays.has(recordArray)) { + identifiersToAdd.push(identifier); + recordArrays.add(recordArray); + } + } + + if (!shouldInclude) { + identifiersToRemove.push(identifier); + recordArrays.delete(recordArray); } } - if (isDeleted) { - modelsToRemove.push(internalModel); - recordArrays.delete(array); + if (identifiersToAdd.length > 0) { + pushIdentifiers(recordArray, identifiersToAdd, internalModelFactoryFor(store)); } - } + if (identifiersToRemove.length > 0) { + removeIdentifiers(recordArray, identifiersToRemove, internalModelFactoryFor(store)); + } + }; - if (modelsToAdd.length > 0) { - array._pushInternalModels(modelsToAdd); - } - if (modelsToRemove.length > 0) { - array._removeInternalModels(modelsToRemove); - } -} + const pushIdentifiers = function pushIdentifiers(recordArray, identifiers, cache) { + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT && !recordArray._pushIdentifiers) { + // deprecate('not allowed to use this intimate api any more'); + recordArray._pushInternalModels(identifiers.map(i => peekIMCache(cache, i))); + } else { + recordArray._pushIdentifiers(identifiers); + } + }; + const removeIdentifiers = function removeIdentifiers(recordArray, identifiers, cache) { + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT && !recordArray._removeIdentifiers) { + // deprecate('not allowed to use this intimate api any more'); + recordArray._removeInternalModels(identifiers.map(i => peekIMCache(cache, i))); + } else { + recordArray._removeIdentifiers(identifiers); + } + }; -function removeFromAdapterPopulatedRecordArrays(internalModels) { - for (let i = 0; i < internalModels.length; i++) { - removeFromAll(internalModels[i]); - } -} + const removeFromAdapterPopulatedRecordArrays = function removeFromAdapterPopulatedRecordArrays(store, identifiers) { + for (let i = 0; i < identifiers.length; i++) { + removeFromAll(store, identifiers[i]); + } + }; -function removeFromAll(internalModel) { - const recordArrays = internalModel._recordArrays; + const removeFromAll = function removeFromAll(store, identifier) { + identifier = getIdentifier(identifier); + const recordArrays = recordArraysForIdentifier(identifier); + const cache = internalModelFactoryFor(store); - recordArrays.forEach(function(recordArray) { - recordArray._removeInternalModels([internalModel]); - }); + recordArrays.forEach(function(recordArray) { + removeIdentifiers(recordArray, [identifier], cache); + }); - recordArrays.clear(); + recordArrays.clear(); + }; } -export function associateWithRecordArray(internalModels, array) { - for (let i = 0, l = internalModels.length; i < l; i++) { - let internalModel = internalModels[i]; - internalModel._recordArrays.add(array); - } -} +export { associateWithRecordArray }; + +export default RecordArrayManager; diff --git a/packages/store/addon/-private/system/record-arrays/adapter-populated-record-array.js b/packages/store/addon/-private/system/record-arrays/adapter-populated-record-array.js index f601ff29c32..f698a0a23c3 100644 --- a/packages/store/addon/-private/system/record-arrays/adapter-populated-record-array.js +++ b/packages/store/addon/-private/system/record-arrays/adapter-populated-record-array.js @@ -4,6 +4,7 @@ import { assign } from '@ember/polyfills'; import { once } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; import { DEPRECATE_EVENTED_API_USAGE } from '@ember-data/private-build-infra/deprecations'; import RecordArray from './record-array'; @@ -49,9 +50,8 @@ import RecordArray from './record-array'; @class AdapterPopulatedRecordArray @extends RecordArray */ -export default RecordArray.extend({ +let AdapterPopulatedRecordArray = RecordArray.extend({ init() { - // yes we are touching `this` before super, but ArrayProxy has a bug that requires this. this.set('content', this.get('content') || A()); this._super(...arguments); @@ -75,17 +75,11 @@ export default RecordArray.extend({ return store._query(this.modelName, query, this); }, - /** - @method _setInternalModels - @param {Array} internalModels - @param {Object} payload normalized payload - @private - */ - _setInternalModels(internalModels, payload) { + _setObjects(identifiersOrInternalModels, payload) { // TODO: initial load should not cause change events at all, only // subsequent. This requires changing the public api of adapter.query, but // hopefully we can do that soon. - this.get('content').setObjects(internalModels); + this.get('content').setObjects(identifiersOrInternalModels); this.setProperties({ isLoaded: true, @@ -94,10 +88,10 @@ export default RecordArray.extend({ links: assign({}, payload.links), }); - this.manager._associateWithRecordArray(internalModels, this); + this.manager._associateWithRecordArray(identifiersOrInternalModels, this); if (DEPRECATE_EVENTED_API_USAGE) { - const _hasDidLoad = DEBUG ? this._has('didLoad') : this.has('didLoad'); + let _hasDidLoad = DEBUG ? this._has('didLoad') : this.has('didLoad'); if (_hasDidLoad) { // TODO: should triggering didLoad event be the last action of the runLoop? once(this, 'trigger', 'didLoad'); @@ -105,3 +99,31 @@ export default RecordArray.extend({ } }, }); + +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + AdapterPopulatedRecordArray = AdapterPopulatedRecordArray.extend({ + /** + @method _setIdentifiers + @param {StableRecordIdentifier[]} identifiers + @param {Object} payload normalized payload + @internal + */ + _setIdentifiers(identifiers, payload) { + this._setObjects(identifiers, payload); + }, + }); +} else { + AdapterPopulatedRecordArray = AdapterPopulatedRecordArray.extend({ + /** + @method _setInternalModels + @param {Array} internalModels + @param {Object} payload normalized payload + @internal + */ + _setInternalModels(internalModels, payload) { + this._setObjects(internalModels, payload); + }, + }); +} + +export default AdapterPopulatedRecordArray; diff --git a/packages/store/addon/-private/system/record-arrays/record-array.js b/packages/store/addon/-private/system/record-arrays/record-array.js index 22a32c19f83..edb3e725c29 100644 --- a/packages/store/addon/-private/system/record-arrays/record-array.js +++ b/packages/store/addon/-private/system/record-arrays/record-array.js @@ -7,9 +7,18 @@ import { DEBUG } from '@glimmer/env'; import { Promise } from 'rsvp'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS } from '@ember-data/canary-features'; + import DeprecatedEvented from '../deprecated-evented'; import { PromiseArray } from '../promise-proxies'; import SnapshotRecordArray from '../snapshot-record-array'; +import { internalModelFactoryFor } from '../store/internal-model-factory'; + +function recordForIdentifier(store, identifier) { + return internalModelFactoryFor(store) + .lookup(identifier) + .getRecord(); +} /** A record array is an array that contains records of a certain modelName. The record @@ -23,9 +32,9 @@ import SnapshotRecordArray from '../snapshot-record-array'; @uses Ember.Evented */ -export default ArrayProxy.extend(DeprecatedEvented, { - init() { - this._super(...arguments); +let RecordArray = ArrayProxy.extend(DeprecatedEvented, { + init(args) { + this._super(args); if (DEBUG) { this._getDeprecatedEventedInfo = () => `RecordArray containing ${this.modelName}`; @@ -113,8 +122,13 @@ export default ArrayProxy.extend(DeprecatedEvented, { @return {Model} record */ objectAtContent(index) { - let internalModel = get(this, 'content').objectAt(index); - return internalModel && internalModel.getRecord(); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + let identifier = get(this, 'content').objectAt(index); + return identifier ? recordForIdentifier(this.store, identifier) : undefined; + } else { + let internalModel = get(this, 'content').objectAt(index); + return internalModel ? internalModel.getRecord() : undefined; + } }, /** @@ -164,31 +178,6 @@ export default ArrayProxy.extend(DeprecatedEvented, { return this.store.findAll(this.modelName, { reload: true }); }, - /** - Adds an internal model to the `RecordArray` without duplicates - - @method _pushInternalModels - @private - @param {InternalModel} internalModel - */ - _pushInternalModels(internalModels) { - // pushObjects because the internalModels._recordArrays set was already - // consulted for inclusion, so addObject and its on .contains call is not - // required. - get(this, 'content').pushObjects(internalModels); - }, - - /** - Removes an internalModel to the `RecordArray`. - - @method removeInternalModel - @private - @param {InternalModel} internalModel - */ - _removeInternalModels(internalModels) { - get(this, 'content').removeObjects(internalModels); - }, - /** Saves all of the records in the `RecordArray`. @@ -216,16 +205,6 @@ export default ArrayProxy.extend(DeprecatedEvented, { return PromiseArray.create({ promise }); }, - _dissociateFromOwnRecords() { - this.get('content').forEach(internalModel => { - let recordArrays = internalModel.__recordArrays; - - if (recordArrays) { - recordArrays.delete(this); - } - }); - }, - /** @method _unregisterFromManager @private @@ -257,12 +236,105 @@ export default ArrayProxy.extend(DeprecatedEvented, { // this is private for users, but public for ember-data internals return new SnapshotRecordArray(this, this.get('meta'), options); }, - - /* - @method _takeSnapshot - @private - */ - _takeSnapshot() { - return get(this, 'content').map(internalModel => internalModel.createSnapshot()); - }, }); + +if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + RecordArray = RecordArray.extend({ + /** + @method _dissociateFromOwnRecords + @internal + */ + _dissociateFromOwnRecords() { + this.get('content').forEach(identifier => { + let recordArrays = this.manager.getRecordArraysForIdentifier(identifier); + + if (recordArrays) { + recordArrays.delete(this); + } + }); + }, + + /** + Adds identifiers to the `RecordArray` without duplicates + + @method _pushIdentifiers + @internal + @param {StableRecordIdentifier[]} identifiers + */ + _pushIdentifiers(identifiers) { + get(this, 'content').pushObjects(identifiers); + }, + + /** + Removes identifiers from the `RecordArray`. + + @method _removeIdentifiers + @internal + @param {StableRecordIdentifier[]} identifiers + */ + _removeIdentifiers(identifiers) { + get(this, 'content').removeObjects(identifiers); + }, + + /** + @method _takeSnapshot + @internal + */ + _takeSnapshot() { + return get(this, 'content').map(identifier => + internalModelFactoryFor(this.store) + .lookup(identifier) + .createSnapshot() + ); + }, + }); +} else { + RecordArray = RecordArray.extend({ + /** + @method _dissociateFromOwnRecords + @internal + */ + _dissociateFromOwnRecords() { + this.get('content').forEach(internalModel => { + let recordArrays = internalModel.__recordArrays; + + if (recordArrays) { + recordArrays.delete(this); + } + }); + }, + + /** + Adds an internal model to the `RecordArray` without duplicates + @method _pushInternalModels + @private + @param {InternalModel} internalModel + */ + _pushInternalModels(internalModels) { + // pushObjects because the internalModels._recordArrays set was already + // consulted for inclusion, so addObject and its on .contains call is not + // required. + get(this, 'content').pushObjects(internalModels); + }, + + /** + Removes an internalModel to the `RecordArray`. + @method _removeInternalModels + @private + @param {InternalModel} internalModel + */ + _removeInternalModels(internalModels) { + get(this, 'content').removeObjects(internalModels); + }, + + /** + @method _takeSnapshot + @internal + */ + _takeSnapshot() { + return get(this, 'content').map(internalModel => internalModel.createSnapshot()); + }, + }); +} + +export default RecordArray; diff --git a/packages/store/addon/-private/system/store/finders.js b/packages/store/addon/-private/system/store/finders.js index 6dc04e354d6..93278f44197 100644 --- a/packages/store/addon/-private/system/store/finders.js +++ b/packages/store/addon/-private/system/store/finders.js @@ -5,7 +5,7 @@ import { DEBUG } from '@glimmer/env'; import { Promise } from 'rsvp'; -import { REQUEST_SERVICE } from '@ember-data/canary-features'; +import { RECORD_ARRAY_MANAGER_IDENTIFIERS, REQUEST_SERVICE } from '@ember-data/canary-features'; import coerceId from '../coerce-id'; import { _bind, _guard, _objectIsAlive, guardDestroyedStore } from './common'; @@ -375,15 +375,29 @@ export function _query(adapter, store, modelName, query, recordArray, options) { 'The response to store.query is expected to be an array but it was a single record. Please wrap your response in an array or use `store.queryRecord` to query for a single record.', Array.isArray(internalModels) ); - if (recordArray) { - recordArray._setInternalModels(internalModels, payload); + if (RECORD_ARRAY_MANAGER_IDENTIFIERS) { + let identifiers = internalModels.map(im => im.identifier); + if (recordArray) { + recordArray._setIdentifiers(identifiers, payload); + } else { + recordArray = store.recordArrayManager.createAdapterPopulatedRecordArray( + modelName, + query, + identifiers, + payload + ); + } } else { - recordArray = store.recordArrayManager.createAdapterPopulatedRecordArray( - modelName, - query, - internalModels, - payload - ); + if (recordArray) { + recordArray._setInternalModels(internalModels, payload); + } else { + recordArray = store.recordArrayManager.createAdapterPopulatedRecordArray( + modelName, + query, + internalModels, + payload + ); + } } return recordArray;