Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: @attr defaultValue() results should persist after initialization #9355

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/json-api/src/-private/cache.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we also need to clear defaultAttrs during patchLocalAttributes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick feedback!

I reviewed patchLocalAttributes and happy to make the change, but feel like I need a bit more clarification. FWICT, the purpose of that method is to simply remove the key/value pairs from localAttrs that are not in remoteAttrs or inflightAttrs. I'm assuming this would occur when, say, a record is updated from the server while there are still local changes? If so, should we also have similar logic to remove any defaultAttrs that have keys present in localAttrs, remoteAttrs, or inflightAttrs?

The way I see it, yes it might be worthwhile to ensure defaultAttrs is kept clean at every possible chance, but because the order of operations of getAttr would inherently prevent a default value from being exposed if any of the other sets of attributes contains that key, it could be considered superfluous.

In thinking through the scenario, if I understand it correctly:

  • A record is fetched remotely that does not define an attribute with a defaultValue function, so the default value is created.
  • The record is returned from the server again, only this time it does have a value for that, thereby masking the previous default value.
  • The record is returned from the server, yet without the attribute once again. What should happen?

This seems like quite an edge case, but I think my opinion would be that the original default value would take effect again, as opposed to a new one being created.

Happy to do what you think makes the most sense here, just let me know!

Also, I was hoping to find a nice set of unit tests for this cache class, but no luck 😕. Do you have a recommendation regarding where to write some tests for this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like quite an edge case, but I think my opinion would be that the original default value would take effect again, as opposed to a new one being created.

If the server has previously informed us of a state, and that state later disappears, imo the correct thing to do is start over.

I was hoping to find a nice set of unit tests for this cache class, but no luck

The cache requires integration tests. Newest ones are here https://github.com/emberjs/data/tree/main/tests/ember-data__json-api/tests/integration/cache and here https://github.com/emberjs/data/blob/main/tests/main/tests/integration/cache/json-api-cache-test.js

but most of them are scattered through the main test app, with most having been written from the perspective of user observable behaviors (due largely to how cache grew out of Store/Model/InternalModel).

I'd recommend adding new tests at that first link above since this is a behavior specific to JSON:API cache.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@christophersansone got any more time for this one? I can poke at it a bit if needed

Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ interface CachedResource {
id: string | null;
remoteAttrs: Record<string, Value | undefined> | null;
localAttrs: Record<string, Value | undefined> | null;
defaultAttrs: Record<string, Value | undefined> | null;
inflightAttrs: Record<string, Value | undefined> | null;
changes: Record<string, [Value | undefined, Value]> | null;
errors: JsonApiError[] | null;
Expand All @@ -90,6 +91,7 @@ function makeCache(): CachedResource {
id: null,
remoteAttrs: null,
localAttrs: null,
defaultAttrs: null,
inflightAttrs: null,
changes: null,
errors: null,
Expand Down Expand Up @@ -1036,6 +1038,7 @@ export default class JSONAPICache implements Cache {
// we report as `isEmpty` during teardown.
cached.localAttrs = null;
cached.remoteAttrs = null;
cached.defaultAttrs = null;
cached.inflightAttrs = null;

const relatedIdentifiers = _allRelatedIdentifiers(storeWrapper, identifier);
Expand Down Expand Up @@ -1096,11 +1099,18 @@ export default class JSONAPICache implements Cache {
return cached.inflightAttrs[attr];
} else if (cached.remoteAttrs && attr in cached.remoteAttrs) {
return cached.remoteAttrs[attr];
} else if (cached.defaultAttrs && attr in cached.defaultAttrs) {
return cached.defaultAttrs[attr];
} else {
const attrSchema = this._capabilities.schema.fields(identifier).get(attr);

upgradeCapabilities(this._capabilities);
return getDefaultValue(attrSchema, identifier, this._capabilities._store);
const defaultValue = getDefaultValue(attrSchema, identifier, this._capabilities._store);
if (typeof attrSchema?.options?.defaultValue === 'function') {
cached.defaultAttrs = cached.defaultAttrs || (Object.create(null) as Record<string, Value>);
cached.defaultAttrs[attr] = defaultValue;
}
return defaultValue;
}
}

Expand Down Expand Up @@ -1133,6 +1143,10 @@ export default class JSONAPICache implements Cache {
delete cached.changes![attr];
}

if (cached.defaultAttrs && attr in cached.defaultAttrs) {
delete cached.defaultAttrs[attr];
}

this._capabilities.notifyChange(identifier, 'attributes', attr);
}

Expand Down Expand Up @@ -1195,6 +1209,7 @@ export default class JSONAPICache implements Cache {
}

cached.inflightAttrs = null;
cached.defaultAttrs = null;

if (cached.errors) {
cached.errors = null;
Expand Down Expand Up @@ -1635,7 +1650,7 @@ function setupRelationships(
}

function patchLocalAttributes(cached: CachedResource): boolean {
const { localAttrs, remoteAttrs, inflightAttrs, changes } = cached;
const { localAttrs, remoteAttrs, inflightAttrs, defaultAttrs, changes } = cached;
if (!localAttrs) {
cached.changes = null;
return false;
Expand All @@ -1657,6 +1672,10 @@ function patchLocalAttributes(cached: CachedResource): boolean {
delete localAttrs[attr];
delete changes![attr];
}

if (defaultAttrs && attr in defaultAttrs) {
delete defaultAttrs[attr];
}
}
return hasAppliedPatch;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,77 @@ module('Integration | @ember-data/json-api Cache.put(<ResourceDataDocument>)', f
'We can fetch more included data from the cache'
);
});

test('generated default values are retained', function (assert) {
const store = new TestStore();
let i = 0;

store.registerSchema(
new TestSchema<'user'>({
user: {
attributes: {
name: {
kind: 'attribute',
name: 'name',
type: null,
options: {
defaultValue: () => {
i++;
return `Name ${i}`;
},
},
},
},
relationships: {},
},
})
);

store._run(() => {
store.cache.put({
content: {
data: {
type: 'user',
id: '1',
attributes: {},
},
},
}) as SingleResourceDataDocument;
});
const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' });

const name1 = store.cache.getAttr(identifier, 'name');
assert.equal(name1, 'Name 1', 'The default value was generated');
const name2 = store.cache.getAttr(identifier, 'name');
assert.equal(name2, 'Name 1', 'The default value was cached');

store.cache.setAttr(identifier, 'name', 'Chris');
const name3 = store.cache.getAttr(identifier, 'name');
assert.equal(name3, 'Chris', 'The value was updated');

store.cache.setAttr(identifier, 'name', null);
const name4 = store.cache.getAttr(identifier, 'name');
assert.equal(name4, null, 'Null was set and maintained');

store.cache.rollbackAttrs(identifier);
const name5 = store.cache.getAttr(identifier, 'name');
assert.equal(name5, 'Name 2', 'The default value was regenerated');

store._run(() => {
store.cache.put({
content: {
data: {
type: 'user',
id: '1',
attributes: {
name: 'Tomster',
},
},
},
}) as SingleResourceDataDocument;
});

const name6 = store.cache.getAttr(identifier, 'name');
assert.equal(name6, 'Tomster', 'The value was updated on put');
});
});
Loading