diff --git a/__tests__/common.ts b/__tests__/common.ts index 7fa3aa99d55..d8aca200b9d 100644 --- a/__tests__/common.ts +++ b/__tests__/common.ts @@ -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'; } diff --git a/packages/core/src/react-integration/__tests__/useResource.web.tsx b/packages/core/src/react-integration/__tests__/useResource.web.tsx index 3e03a115dfe..fbf53ea9bca 100644 --- a/packages/core/src/react-integration/__tests__/useResource.web.tsx +++ b/packages/core/src/react-integration/__tests__/useResource.web.tsx @@ -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'; @@ -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}`) @@ -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); + }); }); diff --git a/packages/normalizr/src/__tests__/__snapshots__/index.test.js.snap b/packages/normalizr/src/__tests__/__snapshots__/index.test.js.snap index 9097a640efb..6d0fdb437cf 100644 --- a/packages/normalizr/src/__tests__/__snapshots__/index.test.js.snap +++ b/packages/normalizr/src/__tests__/__snapshots__/index.test.js.snap @@ -432,7 +432,7 @@ Object { "entities": Object { "Article": Object { "123": Article { - "author": 1, + "author": "1", "id": "123", "title": "normalizr is great!", }, @@ -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 { diff --git a/packages/normalizr/src/__tests__/index.test.js b/packages/normalizr/src/__tests__/index.test.js index a0d86b95b0c..1c7dacfd0d2 100644 --- a/packages/normalizr/src/__tests__/index.test.js +++ b/packages/normalizr/src/__tests__/index.test.js @@ -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', () => { diff --git a/packages/normalizr/src/denormalize.ts b/packages/normalizr/src/denormalize.ts index 5338ad0e583..c583c5270b6 100644 --- a/packages/normalizr/src/denormalize.ts +++ b/packages/normalizr/src/denormalize.ts @@ -46,14 +46,16 @@ const getUnvisit = (entities: Record) => { 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 @@ -97,6 +99,7 @@ const getEntities = (entities: Record) => { }; }; +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const denormalize = ( input: any, schema: S, diff --git a/packages/normalizr/src/entities/Entity.ts b/packages/normalizr/src/entities/Entity.ts index 45c82ac19fb..799853da883 100644 --- a/packages/normalizr/src/entities/Entity.ts +++ b/packages/normalizr/src/entities/Entity.ts @@ -54,6 +54,8 @@ export default abstract class Entity extends SimpleRecord { addEntity: (...args: any) => any, visitedEntities: Record, ): 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 */ @@ -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], diff --git a/packages/normalizr/src/entities/__tests__/SimpleRecord.test.ts b/packages/normalizr/src/entities/__tests__/SimpleRecord.test.ts index 8083e02b824..22db791b29e 100644 --- a/packages/normalizr/src/entities/__tests__/SimpleRecord.test.ts +++ b/packages/normalizr/src/entities/__tests__/SimpleRecord.test.ts @@ -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, }; } @@ -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', () => { @@ -198,6 +227,7 @@ describe('SimpleRecord', () => { { requiredArticle: '5', nextPage: 'blob', + createdAt: new Date('2020-06-07T02:00:15+0000'), }, WithOptional, { @@ -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', () => { @@ -238,7 +274,9 @@ describe('SimpleRecord', () => { article: ArticleEntity.fromJS({ id: '5' }), requiredArticle: ArticleEntity.fromJS(), nextPage: 'blob', + createdAt: null, }); + expect(response.createdAt).toBeNull(); }); }); }); diff --git a/packages/normalizr/src/entities/__tests__/__snapshots__/SimpleRecord.test.ts.snap b/packages/normalizr/src/entities/__tests__/__snapshots__/SimpleRecord.test.ts.snap index 684e0905e33..bad6f9a758d 100644 --- a/packages/normalizr/src/entities/__tests__/__snapshots__/SimpleRecord.test.ts.snap +++ b/packages/normalizr/src/entities/__tests__/__snapshots__/SimpleRecord.test.ts.snap @@ -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 { diff --git a/packages/normalizr/src/normalize.ts b/packages/normalizr/src/normalize.ts index 20d6e982bf7..78517a3091b 100644 --- a/packages/normalizr/src/normalize.ts +++ b/packages/normalizr/src/normalize.ts @@ -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; @@ -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> = Record< diff --git a/packages/normalizr/src/schema.d.ts b/packages/normalizr/src/schema.d.ts index 10cf2054153..8eb9e259420 100644 --- a/packages/normalizr/src/schema.d.ts +++ b/packages/normalizr/src/schema.d.ts @@ -30,6 +30,12 @@ export type UnionResult = { schema: keyof Choices; }; +export type Serializable< + T extends { toJSON(): string } = { toJSON(): string } +> = { + prototype: T; +}; + export interface SchemaSimple { normalize( input: any, diff --git a/packages/normalizr/src/schemas/__tests__/Serializable.test.ts b/packages/normalizr/src/schemas/__tests__/Serializable.test.ts new file mode 100644 index 00000000000..f11f9907bf1 --- /dev/null +++ b/packages/normalizr/src/schemas/__tests__/Serializable.test.ts @@ -0,0 +1,109 @@ +// eslint-env jest +import { denormalizeSimple as denormalize } from '../../denormalize'; +import { normalize } from '../../'; +import IDEntity from '../../entities/IDEntity'; + +class User extends IDEntity { + createdAt = new Date(0); + name = ''; + static schema = { + createdAt: Date, + }; +} +class Other { + thing = 0; + constructor(props) { + this.thing = props.thing; + } + + toJSON() { + return { thing: this.thing }; + } +} +const objectSchema = { + user: User, + anotherItem: Other, + time: Date, +}; + +describe(`Serializable normalization`, () => { + test('normalizes date and custom', () => { + const norm = normalize( + { + user: { + id: '1', + name: 'Nacho', + createdAt: '2020-06-07T02:00:15+0000', + }, + anotherItem: { thing: 500 }, + time: '2020-06-07T02:00:15+0000', + }, + objectSchema, + ); + expect(norm.result.time.getTime()).toBe(norm.result.time.getTime()); + expect(norm.result.anotherItem).toBeInstanceOf(Other); + expect(norm.entities[User.key]['1'].createdAt).toBe( + norm.entities[User.key]['1'].createdAt, + ); + expect(norm).toMatchSnapshot(); + expect(JSON.stringify(norm)).toMatchSnapshot(); + }); +}); + +describe(`Serializable denormalization`, () => { + test('denormalizes date and custom', () => { + const entities = { + User: { + '1': { + id: '1', + name: 'Nacho', + createdAt: new Date('2020-06-07T02:00:15+0000'), + }, + }, + }; + const [response, found] = denormalize( + { + user: '1', + anotherItem: new Other({ thing: 500 }), + time: new Date('2020-06-07T02:00:15+0000'), + }, + objectSchema, + entities, + ); + expect(response.anotherItem).toBeInstanceOf(Other); + expect(response.time.getTime()).toBe(response.time.getTime()); + expect(response.user?.createdAt.getTime()).toBe( + response.user?.createdAt.getTime(), + ); + expect(found).toBe(true); + expect(response).toMatchSnapshot(); + }); + + test('denormalizes as plain', () => { + const entities = { + User: { + '1': { + id: '1', + name: 'Nacho', + createdAt: '2020-06-07T02:00:15+0000', + }, + }, + }; + const [response, found] = denormalize( + { + user: '1', + anotherItem: { thing: 500 }, + time: '2020-06-07T02:00:15+0000', + }, + objectSchema, + entities, + ); + expect(response.anotherItem).toBeInstanceOf(Other); + expect(response.time.getTime()).toBe(response.time.getTime()); + expect(response.user?.createdAt.getTime()).toBe( + response.user?.createdAt.getTime(), + ); + expect(found).toBe(true); + expect(response).toMatchSnapshot(); + }); +}); diff --git a/packages/normalizr/src/schemas/__tests__/__snapshots__/Serializable.test.ts.snap b/packages/normalizr/src/schemas/__tests__/__snapshots__/Serializable.test.ts.snap new file mode 100644 index 00000000000..85f7b6db6dc --- /dev/null +++ b/packages/normalizr/src/schemas/__tests__/__snapshots__/Serializable.test.ts.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Serializable denormalization denormalizes as plain 1`] = ` +Object { + "anotherItem": Object { + "thing": 500, + }, + "time": 2020-06-07T02:00:15.000Z, + "user": User { + "createdAt": 2020-06-07T02:00:15.000Z, + "id": "1", + "name": "Nacho", + }, +} +`; + +exports[`Serializable denormalization denormalizes date and custom 1`] = ` +Object { + "anotherItem": Object { + "thing": 500, + }, + "time": 2020-06-07T02:00:15.000Z, + "user": User { + "createdAt": 2020-06-07T02:00:15.000Z, + "id": "1", + "name": "Nacho", + }, +} +`; + +exports[`Serializable normalization normalizes date and custom 1`] = ` +Object { + "entities": Object { + "User": Object { + "1": User { + "createdAt": 2020-06-07T02:00:15.000Z, + "id": "1", + "name": "Nacho", + }, + }, + }, + "indexes": Object {}, + "result": Object { + "anotherItem": Object { + "thing": 500, + }, + "time": 2020-06-07T02:00:15.000Z, + "user": "1", + }, +} +`; + +exports[`Serializable normalization normalizes date and custom 2`] = `"{\\"entities\\":{\\"User\\":{\\"1\\":{\\"id\\":\\"1\\",\\"createdAt\\":\\"2020-06-07T02:00:15.000Z\\",\\"name\\":\\"Nacho\\"}}},\\"indexes\\":{},\\"result\\":{\\"user\\":\\"1\\",\\"anotherItem\\":{\\"thing\\":500},\\"time\\":\\"2020-06-07T02:00:15.000Z\\"}}"`; diff --git a/packages/normalizr/src/types.ts b/packages/normalizr/src/types.ts index aefa9deae40..b8e0213caa6 100644 --- a/packages/normalizr/src/types.ts +++ b/packages/normalizr/src/types.ts @@ -64,6 +64,8 @@ export type Denormalize = S extends EntityInterface ? AbstractInstanceType : S extends schema.SchemaClass ? DenormalizeReturnType + : S extends schema.Serializable + ? T : S extends Array ? Denormalize[] : S extends { [K: string]: any } @@ -76,6 +78,8 @@ export type DenormalizeNullable = S extends EntityInterface ? DenormalizeNullableNestedSchema : S extends schema.SchemaClass ? DenormalizeReturnType + : S extends schema.Serializable + ? T : S extends Array ? Denormalize[] | undefined : S extends { [K: string]: any } @@ -88,6 +92,8 @@ export type Normalize = S extends EntityInterface ? NormalizeObject : S extends schema.SchemaClass ? NormalizeReturnType + : S extends schema.Serializable + ? T : S extends Array ? Normalize[] : S extends { [K: string]: any } @@ -100,6 +106,8 @@ export type NormalizeNullable = S extends EntityInterface ? NormalizedNullableObject : S extends schema.SchemaClass ? NormalizeReturnType + : S extends schema.Serializable + ? T : S extends Array ? Normalize[] | undefined : S extends { [K: string]: any } @@ -111,7 +119,8 @@ export type Schema = | string | { [K: string]: any } | Schema[] - | schema.SchemaSimple; + | schema.SchemaSimple + | schema.Serializable; export type NormalizedIndex = { readonly [entityKey: string]: {