@data-client/normalizr
Version:
Normalizes and denormalizes JSON according to schema for Redux and Flux applications
1,066 lines (995 loc) • 30 kB
text/typescript
// eslint-env jest
import { Entity } from '@data-client/endpoint';
import { schema as schemas } from '@data-client/endpoint';
import {
UnionResource,
CoolerArticle,
IndexedUser,
FirstUnion,
} from '__tests__/new';
import { fromJSState } from './immutable.test';
import { IQueryDelegate } from '../interface';
import MemoCache from '../memo/MemoCache';
import { MemoPolicy as POJOPolicy } from '../memo/Policy';
import { MemoPolicy as ImmPolicy } from '../memo/Policy.imm';
class IDEntity extends Entity {
id = '';
pk() {
return this.id;
}
}
class Tacos extends IDEntity {
type = '';
}
let dateSpy;
beforeAll(() => {
dateSpy = jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date('2019-05-14T11:01:58.135Z').valueOf());
});
afterAll(() => {
dateSpy.mockRestore();
});
describe('MemoCache', () => {
describe('denormalize', () => {
test('denormalizes suspends when symbol contains DELETED string', () => {
const entities = {
Tacos: {
1: Symbol('ENTITY WAS DELETED'),
},
};
expect(new MemoCache().denormalize(Tacos, '1', entities).data).toEqual(
expect.any(Symbol),
);
});
test('maintains referential equality with same results', () => {
const memo = new MemoCache();
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const result = ['1', '2'];
const schema = [Tacos];
const { data: first, paths: pathsFirst } = memo.denormalize(
schema,
result,
entities,
);
const { data: second, paths: pathsSecond } = memo.denormalize(
schema,
result,
entities,
);
expect(first).toBe(second);
expect(pathsFirst).toEqual(pathsSecond);
const { data: third } = memo.denormalize(schema, [...result], entities);
expect(first).not.toBe(third);
expect(first).toEqual(third);
const fourth = memo.denormalize(schema, result, {
Tacos: { ...entities.Tacos, 2: { id: '2', type: 'bar' } },
}).data;
expect(first).not.toBe(fourth);
expect(first).toEqual(fourth);
});
test('updates when results change, but entities are the same', () => {
const memo = new MemoCache();
const entities = {
Tacos: {
1: { id: '1', type: 'foo' },
2: { id: '2', type: 'bar' },
},
};
const result = { data: ['1', '2'], nextPage: 'initial' };
const schema = { data: [Tacos], nextPage: '' };
const { data: first, paths } = memo.denormalize(schema, result, entities);
const { data: second, paths: pathsSecond } = memo.denormalize(
schema,
{ ...result, nextPage: 'second' },
entities,
);
if (typeof first === 'symbol' || typeof second === 'symbol')
throw new Error();
expect(first.nextPage).toBe('initial');
expect(second.nextPage).toBe('second');
expect(first.data).toEqual(second.data);
if (!first.data || !second.data) throw Error();
for (let i = 0; i < first.data?.length; i++) {
expect(first.data[i]).toBe(second.data[i]);
}
const { data: fourth, paths: fourthPaths } = memo.denormalize(
schema,
result,
{
Tacos: { ...entities.Tacos, 2: { id: '2', type: 'bar' } },
},
);
if (typeof fourth === 'symbol' || !fourth.data) throw new Error();
expect(first).not.toBe(fourth);
expect(first).toEqual(fourth);
expect(first.data[0]).toBe(fourth.data[0]);
});
describe('nested entities', () => {
class User extends IDEntity {
name = '';
}
class Comment extends IDEntity {
comment = '';
user = User.fromJS();
static schema = {
user: User,
};
}
class Article extends IDEntity {
title = '';
body = '';
author = User.fromJS();
comments: Comment[] = [];
static schema = {
author: User,
comments: [Comment],
};
}
const entities = {
Article: {
123: {
author: '8472',
body: 'This article is great.',
comments: ['comment-123-4738'],
id: '123',
title: 'A Great Article',
},
},
Comment: {
'comment-123-4738': {
comment: 'I like it!',
id: 'comment-123-4738',
user: '10293',
},
},
User: {
10293: {
id: '10293',
name: 'Jane',
},
8472: {
id: '8472',
name: 'Paul',
},
},
};
test('maintains referential equality with nested entities', () => {
const memo = new MemoCache();
const result = { data: '123' };
const schema = { data: Article };
const first = memo.denormalize(schema, result, entities).data;
const second = memo.denormalize(schema, result, entities).data;
expect(first).toBe(second);
const third = memo.denormalize(Article, '123', entities).data;
const fourth = memo.denormalize(Article, '123', entities).data;
expect(third).toBe(fourth);
});
test('maintains responds to entity updates for distinct top-level results', () => {
const memo = new MemoCache();
const result1 = { data: '123' };
const result2 = { results: ['123'] };
const first = memo.denormalize(
{ data: Article },
result1,
entities,
).data;
const second = memo.denormalize(
{ results: [Article] },
result2,
entities,
).data;
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
!second.results
)
throw new Error();
expect(first.data).toBe(second.results[0]);
const third = memo.denormalize(Article, '123', entities).data;
expect(third).toBe(first.data);
// now change
const nextState = {
...entities,
Article: {
123: {
...entities.Article[123],
title: 'updated article',
body: 'new body',
},
},
};
const firstChanged = memo.denormalize(
{ data: Article },
result1,
nextState,
).data;
expect(firstChanged).not.toBe(first);
const secondChanged = memo.denormalize(
{ results: [Article] },
result2,
nextState,
).data;
expect(secondChanged).not.toBe(second);
if (
typeof firstChanged === 'symbol' ||
typeof secondChanged === 'symbol'
)
throw new Error('symbol');
expect(firstChanged.data).toBe(secondChanged.results?.[0]);
});
test('handles multi-schema (summary entities)', () => {
class ArticleSummary extends IDEntity {
title = '';
body = '';
author = '';
static schema = {
comments: [Comment],
};
static key = 'Article';
}
const memo = new MemoCache();
// we have different result values to represent different endpoint inputs
const resultA = { data: '123' };
const resultB = { data: '123' };
const resultC = '123';
const firstSchema = { data: ArticleSummary };
const secondSchema = { data: Article };
const first = memo.denormalize(firstSchema, resultA, entities).data;
const second = memo.denormalize(secondSchema, resultB, entities).data;
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
!second.data ||
!first.data
)
throw new Error();
// show distinction between how they are denormalized
expect(first.data.author).toMatchInlineSnapshot(`"8472"`);
expect(second.data.author).toMatchInlineSnapshot(`
User {
"id": "8472",
"name": "Paul",
}
`);
expect(first.data).not.toBe(second.data);
const firstWithoutChange = memo.denormalize(
firstSchema,
resultA,
entities,
).data;
expect(first).toBe(firstWithoutChange);
const third = memo.denormalize(Article, resultC, entities).data;
expect(third).toBe(second.data);
// now change
const nextState = {
...entities,
Article: {
123: {
...entities.Article[123],
title: 'updated article',
body: 'new body',
},
},
};
const firstChanged = memo.denormalize(
firstSchema,
resultA,
nextState,
).data;
expect(firstChanged).not.toBe(first);
const secondChanged = memo.denormalize(
secondSchema,
resultB,
nextState,
).data;
expect(secondChanged).not.toBe(second);
if (
typeof firstChanged === 'symbol' ||
typeof secondChanged === 'symbol' ||
!firstChanged.data ||
!secondChanged.data
)
throw new Error();
expect(firstChanged.data.author).toMatchInlineSnapshot(`"8472"`);
expect(secondChanged.data.author).toMatchInlineSnapshot(`
User {
"id": "8472",
"name": "Paul",
}
`);
});
test('entity equality changes', () => {
const memo = new MemoCache();
const result = { data: '123' };
const { data: first } = memo.denormalize(
{ data: Article },
result,
entities,
);
const { data: second } = memo.denormalize({ data: Article }, result, {
...entities,
Article: {
123: {
author: '8472',
body: 'This article is great.',
comments: ['comment-123-4738'],
id: '123',
title: 'A Great Article',
},
},
});
expect(first).not.toBe(second);
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
!second.data ||
!first.data
)
throw new Error();
expect(first.data.author).toBe(second.data.author);
expect(first.data.comments[0]).toBe(second.data.comments[0]);
});
test('nested entity equality changes', () => {
const memo = new MemoCache();
const result = { data: '123' };
const { data: first } = memo.denormalize(
{ data: Article },
result,
entities,
);
const { data: second } = memo.denormalize({ data: Article }, result, {
...entities,
Comment: {
'comment-123-4738': {
comment: 'Updated comment!',
id: 'comment-123-4738',
user: '10293',
},
},
});
expect(first).not.toBe(second);
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
!second.data ||
!first.data
)
throw new Error();
expect(first.data.title).toBe(second.data.title);
expect(first.data.author).toBe(second.data.author);
expect(second.data.comments[0].comment).toEqual('Updated comment!');
expect(first.data.comments[0]).not.toBe(second.data.comments[0]);
expect(first.data.comments[0].user).toBe(second.data.comments[0].user);
});
test('nested entity becomes present in entity table', () => {
const memo = new MemoCache();
const result = { data: '123' };
const emptyEntities = {
...entities,
// no Users exist
User: {},
};
const { data: first } = memo.denormalize(
{ data: Article },
result,
emptyEntities,
);
const { data: second } = memo.denormalize(
{ data: Article },
result,
emptyEntities,
);
const { data: third } = memo.denormalize(
{ data: Article },
result,
// now has users
entities,
);
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
typeof third === 'symbol' ||
!second.data ||
!first.data ||
!third.data
)
throw new Error();
expect(first.data.title).toBe(third.data.title);
expect(first.data.author).toBeUndefined();
// maintain cache when nested value is undefined
expect(first.data).toBe(second.data);
expect(first).toBe(second);
// update value when nested value becomes defined
expect(third.data.author).toBeDefined();
expect(third.data.author.name).toEqual(expect.any(String));
expect(first).not.toBe(third);
});
test('nested entity becomes present in entity table with numbers', () => {
const memo = new MemoCache();
class User extends IDEntity {
name = '';
}
class Article extends IDEntity {
title = '';
author = User.fromJS();
static schema = {
author: User,
};
}
const result = { data: 123 };
const entities = {
Article: {
123: {
author: 8472,
id: 123,
title: 'A Great Article',
},
},
User: {
8472: {
id: 8472,
name: 'Paul',
},
},
};
const emptyEntities = {
...entities,
// no Users exist
User: {},
};
const { data: first } = memo.denormalize(
{ data: Article },
result,
emptyEntities,
);
const { data: second } = memo.denormalize(
{ data: Article },
result,
emptyEntities,
);
const { data: third } = memo.denormalize(
{ data: Article },
result,
// now has users
entities,
);
if (
typeof first === 'symbol' ||
typeof second === 'symbol' ||
typeof third === 'symbol' ||
!second.data ||
!first.data ||
!third.data
)
throw new Error();
expect(first.data.title).toBe(third.data.title);
expect(first.data.author).toBeUndefined();
// maintain cache when nested value is undefined
expect(first.data).toBe(second.data);
expect(first).toBe(second);
// update value when nested value becomes defined
expect(third.data.author).toBeDefined();
expect(third.data.author.name).toEqual(expect.any(String));
expect(first).not.toBe(third);
});
});
test('denormalizes plain object with no entities', () => {
const memo = new MemoCache();
const input = {
firstThing: { five: 5, seven: 42 },
secondThing: { cars: 'fifo' },
};
const schema = {
firstThing: { five: 0, seven: 0 },
secondThing: { cars: '' },
};
const { data: first } = memo.denormalize(schema, input, {});
expect(first).toEqual(input);
// should maintain referential equality
const { data: second } = memo.denormalize(schema, input, {});
expect(second).toBe(first);
});
test('passthrough for null schema and an object input', () => {
const memo = new MemoCache();
const input = {
firstThing: { five: 5, seven: 42 },
secondThing: { cars: 'never' },
};
const { data } = memo.denormalize(null, input, {});
expect(data).toBe(input);
});
test('passthrough for null schema and an number input', () => {
const memo = new MemoCache();
const input = 5;
const { data } = memo.denormalize(null, input, {});
expect(data).toBe(input);
});
test('passthrough for undefined schema and an object input', () => {
const memo = new MemoCache();
const input = {
firstThing: { five: 5, seven: 42 },
secondThing: { cars: 'never' },
};
const { data } = memo.denormalize(undefined, input, {});
expect(data).toBe(input);
});
describe('null inputs when expecting entities', () => {
class User extends IDEntity {}
class Comment extends IDEntity {
comment = '';
static schema = {
user: User,
};
}
class Article extends IDEntity {
title = '';
body = '';
author = User.fromJS({});
comments = [];
static schema = {
author: User,
comments: [Comment],
};
}
test('handles null at top level', () => {
const memo = new MemoCache();
const denorm = memo.denormalize({ data: Article }, null, {}).data;
expect(denorm).toEqual(null);
});
test('handles undefined at top level', () => {
const memo = new MemoCache();
const denorm = memo.denormalize({ data: Article }, undefined, {}).data;
expect(denorm).toEqual(undefined);
});
test('handles null in nested place', () => {
const memo = new MemoCache();
const input = {
data: { id: '5', title: 'hehe', author: null, comments: [] },
};
const denorm = memo.denormalize({ data: Article }, input, {}).data;
expect(denorm).toMatchInlineSnapshot(`
{
"data": Article {
"author": null,
"body": "",
"comments": [],
"id": "5",
"title": "hehe",
},
}
`);
});
});
});
describe('buildQueryKey', () => {
it('should work with Object', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: CoolerArticle,
}),
});
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toEqual({
data: { article: 5 },
});
});
it('should work with number argument', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: CoolerArticle,
}),
});
expect(
new MemoCache().buildQueryKey(schema, [5], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toEqual({
data: { article: '5' },
});
});
it('should work with string argument', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: CoolerArticle,
}),
});
expect(
new MemoCache().buildQueryKey(schema, ['5'], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toEqual({
data: { article: '5' },
});
});
it('should be undefined with Array', () => {
const schema = {
data: new schemas.Array(CoolerArticle),
};
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toStrictEqual({
data: undefined,
});
const schema2 = {
data: [CoolerArticle],
};
expect(
new MemoCache().buildQueryKey(schema2, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toStrictEqual({
data: undefined,
});
});
it('should be undefined with Values', () => {
const schema = {
data: new schemas.Values(CoolerArticle),
};
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: { '5': {} },
},
indexes: {},
}),
).toStrictEqual({
data: undefined,
});
});
it('should be undefined with Union and type', () => {
const schema = UnionResource.get.schema;
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: {
'5': {},
},
},
indexes: {},
}),
).toBe(undefined);
});
it('should work with Union', () => {
const schema = UnionResource.get.schema;
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5, type: 'first' }], {
entities: {
[FirstUnion.key]: {
'5': {},
},
},
indexes: {},
}),
).toMatchInlineSnapshot(`
{
"id": 5,
"schema": "first",
}
`);
});
it('should work with primitive defaults', () => {
const schema = {
pagination: { next: '', previous: '' },
data: CoolerArticle,
};
expect(
new MemoCache().buildQueryKey(schema, [{ id: 5 }], {
entities: {
[CoolerArticle.key]: {
'5': {},
},
},
indexes: {},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: 5,
});
});
it('should work with indexes', () => {
const schema = {
pagination: { next: '', previous: '' },
data: IndexedUser,
};
expect(
new MemoCache().buildQueryKey(schema, [{ username: 'bob' }], {
entities: {
[IndexedUser.key]: {
'5': {},
},
},
indexes: {
[IndexedUser.key]: {
username: {
bob: '5',
},
},
},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: '5',
});
expect(
new MemoCache().buildQueryKey(
schema,
[{ username: 'bob', mary: 'five' }],
{
entities: {
[IndexedUser.key]: {
'5': {},
},
},
indexes: {
[IndexedUser.key]: {
username: {
bob: '5',
},
},
},
},
),
).toEqual({
pagination: { next: '', previous: '' },
data: '5',
});
});
it('should work with indexes but none set', () => {
const schema = {
pagination: { next: '', previous: '' },
data: IndexedUser,
};
expect(
new MemoCache().buildQueryKey(schema, [{ username: 'bob' }], {
entities: {
[IndexedUser.key]: {
'5': {},
},
},
indexes: {
[IndexedUser.key]: {
username: {
charles: '5',
},
},
},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: undefined,
});
expect(
new MemoCache().buildQueryKey(schema, [{ hover: 'bob' }], {
entities: {
[IndexedUser.key]: {
'5': {},
},
},
indexes: {
[IndexedUser.key]: {
username: {
charles: '5',
},
},
},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: undefined,
});
});
it('should work with indexes but no indexes stored', () => {
const schema = {
pagination: { next: '', previous: '' },
data: IndexedUser,
};
expect(
new MemoCache().buildQueryKey(schema, [{ username: 'bob' }], {
entities: { [IndexedUser.key]: { '5': {} } },
indexes: {},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: undefined,
});
expect(
new MemoCache().buildQueryKey(schema, [{ hover: 'bob' }], {
entities: { [IndexedUser.key]: { '5': {} } },
indexes: {},
}),
).toEqual({
pagination: { next: '', previous: '' },
data: undefined,
});
});
describe('legacy schema', () => {
class MyEntity extends CoolerArticle {
static queryKey(args: any[], unvisit: any, delegate: IQueryDelegate) {
if (!args[0]) return;
let pk: any;
if (['string', 'number'].includes(typeof args[0])) {
pk = `${args[0]}`;
} else {
pk = this.pk(args[0], undefined, '', args);
}
// Was able to infer the entity's primary key from params
if (pk !== undefined && pk !== '' && delegate.getEntity(this.key, pk))
return pk;
}
}
it('should work with string argument', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: MyEntity,
}),
});
expect(
new MemoCache().buildQueryKey(schema, ['5'], {
entities: {
[MyEntity.key]: { '5': {} },
},
indexes: {},
}),
).toEqual({
data: { article: '5' },
});
});
it('should be undefined even when Entity.queryKey gives id if entity is not present', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: MyEntity,
}),
});
const memo = new MemoCache();
expect(
memo.buildQueryKey(schema, ['5'], { entities: {}, indexes: {} }),
).toEqual({
data: { article: undefined },
});
expect(
memo.buildQueryKey(schema, ['5'], {
entities: { [MyEntity.key]: { '5': { id: '5', title: 'hi' } } },
indexes: {},
}),
).toEqual({
data: { article: '5' },
});
});
describe('referential equality', () => {
const schema = new schemas.Object({
data: new schemas.Object({
article: MyEntity,
}),
});
const memo = new MemoCache();
const state = {
entities: { [MyEntity.key]: {} },
indexes: {},
};
const first = memo.buildQueryKey(schema, ['5'], state);
it('should maintain referential equality', () => {
expect(memo.buildQueryKey(schema, ['5'], state)).toBe(first);
});
it('should not change on index update if not used', () => {
expect(
memo.buildQueryKey(schema, ['5'], {
...state,
indexes: {
[MyEntity.key]: {},
},
}),
).toBe(first);
});
it('should be new when entity is updated', () => {
const withEntity = memo.buildQueryKey(schema, ['5'], {
entities: {
[MyEntity.key]: { '5': { id: '5', title: 'hi' } },
},
indexes: state.indexes,
});
expect(withEntity).not.toBe(first);
expect(withEntity.data).toEqual({ article: '5' });
});
it('should be the same if other entities are updated', () => {
const withEntity = memo.buildQueryKey(schema, ['5'], {
entities: {
...state.entities,
['another']: { '5': { id: '5', title: 'hi' } },
},
indexes: state.indexes,
});
expect(withEntity).toBe(first);
});
it('should be the same if other entities of the same type are updated', () => {
const withEntity = memo.buildQueryKey(schema, ['5'], {
entities: {
...state.entities,
[MyEntity.key]: {
...state.entities[MyEntity.key],
'500': { id: '500', title: 'second title' },
},
},
indexes: state.indexes,
});
expect(withEntity).toBe(first);
});
});
});
});
describe.each([
['direct', <T>(data: T) => data, POJOPolicy],
['immutable', fromJSState, ImmPolicy],
])(`query (%s)`, (_, createInput, Delegate) => {
class Cat extends IDEntity {
id = '0';
name = '';
username = '';
static indexes = ['username' as const];
}
const state = createInput({
entities: {
Cat: {
1: { id: '1', name: 'Milo', username: 'm' },
2: { id: '2', name: 'Jake', username: 'j' },
3: { id: '3', name: 'Zeta', username: 'z' },
},
},
indexes: {
Cat: {
username: { m: '1', j: '2', z: '3' },
},
},
});
test('works with indexes', () => {
const m = new MemoCache(Delegate).query(
Cat,
[{ username: 'm' }],
state,
).data;
expect(m).toBeDefined();
expect(m).toMatchSnapshot();
expect(
new MemoCache(Delegate).query(
Cat,
[{ username: 'doesnotexist' }],
state,
).data,
).toBeUndefined();
});
test('works with pk', () => {
const m = new MemoCache(Delegate).query(Cat, [{ id: '1' }], state);
expect(m).toBeDefined();
expect(m).toMatchSnapshot();
expect(
new MemoCache(Delegate).query(Cat, [{ id: 'doesnotexist' }], state)
.data,
).toBeUndefined();
});
});
});