Skip to content

Commit 3d07414

Browse files
authored
chore: Improved test coverage for internal relationship state (emberjs#7471)
* chore: improved test coverage for internal relationship state * add nice logging * update lockfile
1 parent 7d40c45 commit 3d07414

File tree

11 files changed

+865
-6
lines changed

11 files changed

+865
-6
lines changed

packages/record-data/addon/-private/record-data.ts

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { isEqual } from '@ember/utils';
88
import { DEBUG } from '@glimmer/env';
99

1010
import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features';
11+
import { removeRecordDataFor } from '@ember-data/store/-private';
1112

1213
import coerceId from './coerce-id';
1314
import Relationships from './relationships/state/create';
@@ -450,6 +451,9 @@ export default class RecordDataDefault implements RelationshipRecordData {
450451
for (let i = 0; i < relatedRecordDatas.length; ++i) {
451452
let recordData = relatedRecordDatas[i];
452453
if (!recordData.isDestroyed) {
454+
// TODO @runspired we do not currently destroy RecordData instances *except* via this relationship
455+
// traversal. This seems like an oversight since the store should be able to notify destroy.
456+
removeRecordDataFor(recordData.identifier);
453457
recordData.destroy();
454458
}
455459
}

packages/record-data/addon/-private/ts-interfaces/relationship-record-data.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier;
12
type SingleResourceRelationship = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').SingleResourceRelationship;
23
type CollectionResourceRelationship = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').CollectionResourceRelationship;
34
type RecordData = import('@ember-data/store/-private/ts-interfaces/record-data').RecordData;
@@ -21,6 +22,7 @@ export interface RelationshipRecordData extends RecordData {
2122
isNew(): boolean;
2223
modelName: string;
2324
storeWrapper: RecordDataStoreWrapper;
25+
identifier: StableRecordIdentifier;
2426
id: string | null;
2527
clientId: string | null;
2628
isEmpty(): boolean;

packages/record-data/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"ember-cli-typescript": "^4.1.0"
3030
},
3131
"devDependencies": {
32+
"@ember-data/model": "3.28.0-alpha.0",
3233
"@ember-data/unpublished-test-infra": "3.28.0-alpha.0",
3334
"@ember/optional-features": "^1.3.0",
3435
"broccoli-asset-rev": "^3.0.0",
@@ -51,6 +52,7 @@
5152
"ember-try": "^1.4.0",
5253
"loader.js": "^4.7.0",
5354
"qunit": "^2.10.0",
55+
"qunit-console-grouper": "^0.3.0",
5456
"qunit-dom": "^1.2.0",
5557
"silent-error": "^1.1.1"
5658
},
@@ -67,4 +69,4 @@
6769
"node": "12.16.2",
6870
"yarn": "1.22.4"
6971
}
70-
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { assign } from '@ember/polyfills';
2+
import settled from '@ember/test-helpers/settled';
3+
4+
import { module, test as runTest } from 'qunit';
5+
6+
import { setInitialState, testFinalState } from './helpers';
7+
import { setupGraphTest } from './setup';
8+
9+
/**
10+
* qunit-console-grouper groups by test but includes setup/teardown
11+
* and in-test as all one block. Adding this grouping allows us to
12+
* clearly notice when a log came during the test vs during setup/teardown.
13+
*
14+
* We should upstream this behavior to qunit-console-grouper
15+
*/
16+
async function test(name: string, callback) {
17+
const fn = async function(...args) {
18+
console.groupCollapsed(name); // eslint-disable-line no-console
19+
try {
20+
await callback.call(this, ...args);
21+
} finally {
22+
console.groupEnd(); // eslint-disable-line no-console
23+
console.log(`====(Begin Test Teardown)====`); // eslint-disable-line no-console
24+
}
25+
};
26+
return runTest(name, fn);
27+
}
28+
29+
type TestConfig = import('./helpers').TestConfig;
30+
type Context = import('./setup').Context;
31+
32+
module('Integration | Graph | Nodes', function(hooks) {
33+
setupGraphTest(hooks);
34+
35+
/**
36+
* These are the various configurations we run tests for on the graph
37+
* to ensure things are working.
38+
*
39+
* We don't currently test 1:many or many:1 relationships. It's unclear
40+
* if the semantics of these are different enough to require additional
41+
* scenarios.
42+
*
43+
* name: the name of the test
44+
* async: whether the relationship should be async or sync (both sides will conform to this)
45+
* relType: whether the relationship should be belongsTo (1:1) or hasMany (many:many)
46+
* inverseNull: whether the relationships should specify inverse: null instead of an explicit inverse.
47+
*/
48+
const TestScenarios: TestConfig[] = [
49+
{
50+
name: 'sync belongsTo',
51+
async: false,
52+
relType: 'belongsTo',
53+
inverseNull: false,
54+
},
55+
{
56+
name: 'async belongsTo',
57+
async: true,
58+
relType: 'belongsTo',
59+
inverseNull: false,
60+
},
61+
{
62+
name: 'sync implicit belongsTo',
63+
async: false,
64+
relType: 'belongsTo',
65+
inverseNull: true,
66+
},
67+
{
68+
name: 'async implicit belongsTo',
69+
async: true,
70+
relType: 'belongsTo',
71+
inverseNull: true,
72+
},
73+
{
74+
name: 'sync hasMany',
75+
async: false,
76+
relType: 'hasMany',
77+
inverseNull: false,
78+
},
79+
{
80+
name: 'async hasMany',
81+
async: true,
82+
relType: 'hasMany',
83+
inverseNull: false,
84+
},
85+
{
86+
name: 'sync implicit hasMany',
87+
async: false,
88+
relType: 'hasMany',
89+
inverseNull: true,
90+
},
91+
{
92+
name: 'async implicit hasMany',
93+
async: true,
94+
relType: 'hasMany',
95+
inverseNull: true,
96+
},
97+
].map(v => (Object.freeze ? Object.freeze(v) : v) as TestConfig);
98+
99+
module('Unpersisted Deletion of Record does not remove it from the graph', function() {
100+
function unpersistedDeletionTest(config: TestConfig) {
101+
test(config.name, async function(this: Context, assert) {
102+
const testState = await setInitialState(this, config, assert);
103+
const { john } = testState;
104+
105+
// now we delete
106+
john.deleteRecord();
107+
108+
// just in case there is a backburner flush
109+
await settled();
110+
111+
/**
112+
* For deletions, since no state change has been persisted, we expect the cache to still
113+
* reflect the same state of the relationship as prior to the call to deleteRecord.
114+
*
115+
* Ergo we expect no entries removed (`removed: false`) and for no caches
116+
* to have been deleted (`cleared: false`)
117+
*
118+
* However: for a newly created record any form of rollback, unload or persisted delete
119+
* will result in it being destroyed and cleared
120+
*/
121+
await testFinalState(
122+
this,
123+
testState,
124+
config,
125+
{ removed: !!config.useCreate, cleared: !!config.useCreate, implicitCleared: !!config.useCreate },
126+
assert
127+
);
128+
});
129+
}
130+
131+
TestScenarios.forEach(unpersistedDeletionTest);
132+
TestScenarios.forEach(testConfig => {
133+
const config = assign({}, testConfig, { name: `[Newly Created] ${testConfig.name}`, useCreate: true });
134+
unpersistedDeletionTest(config);
135+
});
136+
TestScenarios.forEach(testConfig => {
137+
const config = assign({}, testConfig, { name: `[LOCAL STATE] ${testConfig.name}`, dirtyLocal: true });
138+
unpersistedDeletionTest(config);
139+
});
140+
});
141+
142+
module('Unload of a Record does not remove it from the graph', function() {
143+
function unloadTest(_config: TestConfig) {
144+
test(_config.name, async function(this: Context, assert) {
145+
const config = assign({}, _config, { isUnloadAsDelete: true });
146+
const testState = await setInitialState(this, config, assert);
147+
const { john } = testState;
148+
149+
// now we unload
150+
john.unloadRecord();
151+
152+
// just in case there is a backburner flush
153+
await settled();
154+
155+
/**
156+
* For unload, we treat it as a persisted deletion for new records and for sync relationships and
157+
* as no-change for async relationships.
158+
*
159+
* For local-changes of implicit hasMany relationships we expect the relationships to be cleared as well,
160+
* this special case is handled within ./helpers.ts and is something we can ideally delete as a behavior
161+
* in the future.
162+
*
163+
* For newly created records we expect the inverse to be cleaned up (chris) but for the relationships
164+
* for the newly created record to be fully intact. There's no particularly good reason for this other than
165+
* we've counted on the record's destroyed state removing these objects from the graph. The inverse relationship
166+
* state containers will have removed any retained info about the newly created record.
167+
*
168+
* Finally, we expect that even though the relationships on `john` could have been removed in the `sync` case
169+
* that they won't be removed in either case from local and only if from remote if dirtyLocal or useCreate is true.
170+
* The relationships in this case will still be removed from chris. We are possibly retaining these relationships
171+
* despite transitioning the record to an `empty` state in the off chance we need to rematerialize the record.
172+
* Likely for most cases this is just a bug.
173+
*
174+
* If this is confusing that's exactly why we've now added this test suite. People depend on this weirdly
175+
* observable behavior, so we want to know when it changes.
176+
*/
177+
178+
// we remove if the record was new or if the relationship was sync (client side delete semantics)
179+
let removed = config.useCreate || !config.async;
180+
// we clear sync non-implicit relationships (client side delete semantics)
181+
let cleared = !config.async && !config.inverseNull;
182+
183+
await testFinalState(this, testState, config, { removed, cleared, implicitCleared: true }, assert);
184+
});
185+
}
186+
187+
TestScenarios.forEach(unloadTest);
188+
TestScenarios.forEach(testConfig => {
189+
const config = assign({}, testConfig, { name: `[Newly Created] ${testConfig.name}`, useCreate: true });
190+
unloadTest(config);
191+
});
192+
TestScenarios.forEach(testConfig => {
193+
const config = assign({}, testConfig, { name: `[LOCAL STATE] ${testConfig.name}`, dirtyLocal: true });
194+
unloadTest(config);
195+
});
196+
});
197+
198+
module('Persisted Deletion w/o dematerialization of Record removes it from the graph', function(hooks) {
199+
function persistedDeletionTest(config: TestConfig) {
200+
test(config.name, async function(this: Context, assert) {
201+
const testState = await setInitialState(this, config, assert);
202+
const { john } = testState;
203+
204+
// now we delete
205+
john.deleteRecord();
206+
207+
// persist the deletion (but note no call to unloadRecord)
208+
await john.save();
209+
210+
/**
211+
* For persisted deletions, we expect the cache to have removed all entries for
212+
* the deleted record from both explicit and implicit inverses.
213+
*
214+
* Ergo we expect entries removed (`removed: true`) and for all caches
215+
* to have been deleted (`cleared: true`)
216+
*
217+
* For unclear reasons, currently sync hasMany relationships are emptied
218+
* but not cleared prior to dematerialization after a persisted delete
219+
* only when there is dirty local state. (`cleared: false`) while the
220+
* implicit caches are still cleared.
221+
*
222+
* This could be either an intentional or unintentional bug caused by the need
223+
* to be able to sometimes resurrect a many array during unload.
224+
*/
225+
let cleared = true;
226+
if (config.relType === 'hasMany' && !config.async && config.dirtyLocal) {
227+
cleared = false;
228+
}
229+
await testFinalState(this, testState, config, { removed: true, cleared, implicitCleared: true }, assert);
230+
});
231+
}
232+
233+
TestScenarios.forEach(persistedDeletionTest);
234+
TestScenarios.forEach(testConfig => {
235+
const config = assign({}, testConfig, { name: `[Newly Created] ${testConfig.name}`, useCreate: true });
236+
persistedDeletionTest(config);
237+
});
238+
TestScenarios.forEach(testConfig => {
239+
const config = assign({}, testConfig, { name: `[LOCAL STATE] ${testConfig.name}`, dirtyLocal: true });
240+
persistedDeletionTest(config);
241+
});
242+
});
243+
244+
module('Persisted Deletion + dematerialization of Record removes it from the graph and cleans up', function(hooks) {
245+
function persistedDeletionUnloadedTest(config: TestConfig) {
246+
test(config.name, async function(this: Context, assert) {
247+
const testState = await setInitialState(this, config, assert);
248+
const { john } = testState;
249+
250+
// now we delete
251+
john.deleteRecord();
252+
await john.save();
253+
john.unloadRecord();
254+
255+
await settled();
256+
257+
await testFinalState(this, testState, config, { removed: true, cleared: true }, assert);
258+
});
259+
}
260+
261+
TestScenarios.forEach(persistedDeletionUnloadedTest);
262+
TestScenarios.forEach(testConfig => {
263+
const config = assign({}, testConfig, { name: `[Newly Created] ${testConfig.name}`, useCreate: true });
264+
persistedDeletionUnloadedTest(config);
265+
});
266+
TestScenarios.forEach(testConfig => {
267+
const config = assign({}, testConfig, { name: `[LOCAL STATE] ${testConfig.name}`, dirtyLocal: true });
268+
persistedDeletionUnloadedTest(config);
269+
});
270+
});
271+
});

0 commit comments

Comments
 (0)