Skip to content

Commit

Permalink
feat: Declarative schema deserialization
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed Jun 7, 2020
1 parent 3839d1b commit 2ca4219
Show file tree
Hide file tree
Showing 13 changed files with 323 additions and 20 deletions.
11 changes: 11 additions & 0 deletions __tests__/common.ts
Expand Up @@ -131,6 +131,17 @@ export class ArticleResource extends Resource {
}
}

export class ArticleTimedResource extends ArticleResource {
readonly createdAt = new Date(0);

static schema = {
...ArticleResource.schema,
createdAt: Date,
};

static urlRoot = 'http://test.com/article-time/';
}

export class UrlArticleResource extends ArticleResource {
readonly url: string = 'happy.com';
}
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/react-integration/__tests__/useResource.web.tsx
Expand Up @@ -4,6 +4,7 @@ import {
InvalidIfStaleArticleResource,
photoShape,
noEntitiesShape,
ArticleTimedResource,
} from '__tests__/common';
import { State } from '@rest-hooks/core';
import { initialState } from '@rest-hooks/core/state/reducer';
Expand Down Expand Up @@ -84,6 +85,8 @@ describe('useResource()', () => {
.reply(200)
.get(`/article-cooler/${payload.id}`)
.reply(200, payload)
.get(`/article-time/${payload.id}`)
.reply(200, { ...payload, createdAt: '2020-06-07T02:00:15+0000' })
.delete(`/article-cooler/${payload.id}`)
.reply(204, '')
.delete(`/article/${payload.id}`)
Expand Down Expand Up @@ -411,4 +414,21 @@ describe('useResource()', () => {
await waitForNextUpdate();
expect(result.current).toStrictEqual(response);
});

it('should work with Serializable shapes', async () => {
const { result, waitForNextUpdate } = renderRestHook(() => {
return useResource(ArticleTimedResource.detailShape(), payload);
});
// null means it threw
expect(result.current).toBe(null);
await waitForNextUpdate();
expect(result.current.createdAt.getDate()).toBe(
result.current.createdAt.getDate(),
);
expect(result.current.createdAt).toEqual(
new Date('2020-06-07T02:00:15+0000'),
);
expect(result.current.id).toEqual(payload.id);
expect(result.current).toBeInstanceOf(ArticleTimedResource);
});
});
Expand Up @@ -432,7 +432,7 @@ Object {
"entities": Object {
"Article": Object {
"123": Article {
"author": 1,
"author": "1",
"id": "123",
"title": "normalizr is great!",
},
Expand All @@ -443,6 +443,16 @@ Object {
}
`;

exports[`normalize passes over pre-normalized values 2`] = `
Object {
"entities": Object {},
"indexes": Object {},
"result": Object {
"user": "1",
},
}
`;

exports[`normalize uses the non-normalized input when getting the ID for an entity 1`] = `
Object {
"entities": Object {
Expand Down
4 changes: 3 additions & 1 deletion packages/normalizr/src/__tests__/index.test.js
Expand Up @@ -307,10 +307,12 @@ describe('normalize', () => {

expect(
normalize(
{ id: '123', title: 'normalizr is great!', author: 1 },
{ id: '123', title: 'normalizr is great!', author: '1' },
Article,
),
).toMatchSnapshot();

expect(normalize({ user: '1' }, { user: User })).toMatchSnapshot();
});

test('can normalize object without proper object prototype inheritance', () => {
Expand Down
19 changes: 11 additions & 8 deletions packages/normalizr/src/denormalize.ts
Expand Up @@ -46,14 +46,16 @@ const getUnvisit = (entities: Record<string, any>) => {
function unvisit(input: any, schema: any): [any, boolean] {
if (!schema) return [input, true];

if (
typeof schema === 'object' &&
(!schema.denormalize || typeof schema.denormalize !== 'function')
) {
const method = Array.isArray(schema)
? ArrayUtils.denormalize
: ObjectUtils.denormalize;
return method(schema, input, unvisit);
if (!schema.denormalize || typeof schema.denormalize !== 'function') {
if (typeof schema === 'function') {
if (input instanceof schema) return [input, true];
return [new schema(input), true];
} else if (typeof schema === 'object') {
const method = Array.isArray(schema)
? ArrayUtils.denormalize
: ObjectUtils.denormalize;
return method(schema, input, unvisit);
}
}

// null is considered intentional, thus always 'found' as true
Expand Down Expand Up @@ -97,6 +99,7 @@ const getEntities = (entities: Record<string, any>) => {
};
};

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const denormalize = <S extends Schema>(
input: any,
schema: S,
Expand Down
7 changes: 3 additions & 4 deletions packages/normalizr/src/entities/Entity.ts
Expand Up @@ -54,6 +54,8 @@ export default abstract class Entity extends SimpleRecord {
addEntity: (...args: any) => any,
visitedEntities: Record<string, any>,
): any {
// pass over already processed entities
if (typeof input === 'string') return input;
// TODO: what's store needs to be a differing type from fromJS
const processedEntity = this.fromJS(input, parent, key);
/* istanbul ignore else */
Expand Down Expand Up @@ -140,10 +142,7 @@ export default abstract class Entity extends SimpleRecord {
visitedEntities[entityType][id].push(input);

Object.keys(this.schema).forEach(key => {
if (
Object.hasOwnProperty.call(processedEntity, key) &&
typeof processedEntity[key] === 'object'
) {
if (Object.hasOwnProperty.call(processedEntity, key)) {
const schema = this.schema[key];
processedEntity[key] = visit(
processedEntity[key],
Expand Down
38 changes: 38 additions & 0 deletions packages/normalizr/src/entities/__tests__/SimpleRecord.test.ts
Expand Up @@ -35,10 +35,12 @@ class WithOptional extends SimpleRecord {
readonly article: ArticleEntity | null = null;
readonly requiredArticle = ArticleEntity.fromJS();
readonly nextPage = '';
readonly createdAt: Date | null = null;

static schema = {
article: ArticleEntity,
requiredArticle: ArticleEntity,
createdAt: Date,
};
}

Expand Down Expand Up @@ -97,6 +99,33 @@ describe('SimpleRecord', () => {
);
expect(normalized).toMatchSnapshot();
});

it('should deserialize Date', () => {
const normalized = normalize(
{
requiredArticle: { id: '5' },
nextPage: 'blob',
createdAt: '2020-06-07T02:00:15.000Z',
},
WithOptional,
);
expect(normalized.result.createdAt.getTime()).toBe(
normalized.result.createdAt.getTime(),
);
expect(normalized).toMatchSnapshot();
});

it('should use default when Date not provided', () => {
const normalized = normalize(
{
requiredArticle: { id: '5' },
nextPage: 'blob',
},
WithOptional,
);
expect(normalized.result.createdAt).toBeUndefined();
expect(normalized).toMatchSnapshot();
});
});

describe('denormalize', () => {
Expand Down Expand Up @@ -198,6 +227,7 @@ describe('SimpleRecord', () => {
{
requiredArticle: '5',
nextPage: 'blob',
createdAt: new Date('2020-06-07T02:00:15+0000'),
},
WithOptional,
{
Expand All @@ -214,7 +244,13 @@ describe('SimpleRecord', () => {
article: null,
requiredArticle: ArticleEntity.fromJS({ id: '5' }),
nextPage: 'blob',
createdAt: new Date('2020-06-07T02:00:15+0000'),
});
// @ts-expect-error
response.createdAt.toISOString();
expect(response.createdAt?.toISOString()).toBe(
'2020-06-07T02:00:15.000Z',
);
});

it('should be marked as not found when required entity is missing', () => {
Expand All @@ -238,7 +274,9 @@ describe('SimpleRecord', () => {
article: ArticleEntity.fromJS({ id: '5' }),
requiredArticle: ArticleEntity.fromJS(),
nextPage: 'blob',
createdAt: null,
});
expect(response.createdAt).toBeNull();
});
});
});
@@ -1,5 +1,46 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`SimpleRecord normalize should deserialize Date 1`] = `
Object {
"entities": Object {
"ArticleEntity": Object {
"5": ArticleEntity {
"author": "",
"content": "",
"id": "5",
"title": "",
},
},
},
"indexes": Object {},
"result": Object {
"createdAt": 2020-06-07T02:00:15.000Z,
"nextPage": "blob",
"requiredArticle": "5",
},
}
`;

exports[`SimpleRecord normalize should use default when Date not provided 1`] = `
Object {
"entities": Object {
"ArticleEntity": Object {
"5": ArticleEntity {
"author": "",
"content": "",
"id": "5",
"title": "",
},
},
},
"indexes": Object {},
"result": Object {
"nextPage": "blob",
"requiredArticle": "5",
},
}
`;

exports[`SimpleRecord normalize should work on nested 1`] = `
Object {
"entities": Object {
Expand Down
12 changes: 7 additions & 5 deletions packages/normalizr/src/normalize.ts
Expand Up @@ -15,14 +15,15 @@ const visit = (
addEntity: any,
visitedEntities: any,
) => {
if (typeof value !== 'object' || !value || !schema) {
if (!value || !schema || !['function', 'object'].includes(typeof schema)) {
return value;
}

if (
typeof schema === 'object' &&
(!schema.normalize || typeof schema.normalize !== 'function')
) {
if (!schema.normalize || typeof schema.normalize !== 'function') {
// serializable
if (typeof schema === 'function') {
return new schema(value);
}
const method = Array.isArray(schema)
? ArrayUtils.normalize
: ObjectUtils.normalize;
Expand Down Expand Up @@ -100,6 +101,7 @@ function expectedSchemaType(schema: Schema) {
: typeof schema;
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const normalize = <
S extends Schema = Schema,
E extends Record<string, Record<string, any>> = Record<
Expand Down
6 changes: 6 additions & 0 deletions packages/normalizr/src/schema.d.ts
Expand Up @@ -30,6 +30,12 @@ export type UnionResult<Choices extends EntityMap> = {
schema: keyof Choices;
};

export type Serializable<
T extends { toJSON(): string } = { toJSON(): string }
> = {
prototype: T;
};

export interface SchemaSimple {
normalize(
input: any,
Expand Down

0 comments on commit 2ca4219

Please sign in to comment.