From b831a3c25fd9ac213e635e9f87888bc8b297e1a2 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Fri, 12 Jul 2024 15:30:32 +0200 Subject: [PATCH] internal: Split normalize into multiple files --- .../src/__tests__/normalizerMerge.test.tsx | 2 +- packages/normalizr/src/index.ts | 2 +- packages/normalizr/src/normalize.ts | 325 ------------------ .../normalizr/src/normalizr/addEntities.ts | 118 +++++++ .../normalizr/src/normalizr/getCheckLoop.ts | 19 + packages/normalizr/src/normalizr/getVisit.ts | 59 ++++ packages/normalizr/src/normalizr/normalize.ts | 131 +++++++ 7 files changed, 329 insertions(+), 327 deletions(-) delete mode 100644 packages/normalizr/src/normalize.ts create mode 100644 packages/normalizr/src/normalizr/addEntities.ts create mode 100644 packages/normalizr/src/normalizr/getCheckLoop.ts create mode 100644 packages/normalizr/src/normalizr/getVisit.ts create mode 100644 packages/normalizr/src/normalizr/normalize.ts diff --git a/packages/normalizr/src/__tests__/normalizerMerge.test.tsx b/packages/normalizr/src/__tests__/normalizerMerge.test.tsx index bf587ac2185f..0bd34bcea69f 100644 --- a/packages/normalizr/src/__tests__/normalizerMerge.test.tsx +++ b/packages/normalizr/src/__tests__/normalizerMerge.test.tsx @@ -2,7 +2,7 @@ import { schema } from '@data-client/endpoint'; import { Article, IDEntity } from '__tests__/new'; import { denormalize } from '../denormalize/denormalize'; -import { normalize } from '../normalize'; +import { normalize } from '../normalizr/normalize'; describe('normalizer() merging', () => { describe('with instance.constructor.merge()', () => { diff --git a/packages/normalizr/src/index.ts b/packages/normalizr/src/index.ts index de9b74433593..788f08709951 100644 --- a/packages/normalizr/src/index.ts +++ b/packages/normalizr/src/index.ts @@ -6,7 +6,7 @@ Object.hasOwn = import { denormalize } from './denormalize/denormalize.js'; import { isEntity } from './isEntity.js'; import WeakDependencyMap from './memo/WeakDependencyMap.js'; -import { normalize } from './normalize.js'; +import { normalize } from './normalizr/normalize.js'; export { default as MemoCache } from './memo/MemoCache.js'; export type { diff --git a/packages/normalizr/src/normalize.ts b/packages/normalizr/src/normalize.ts deleted file mode 100644 index 4fc9a3f32b48..000000000000 --- a/packages/normalizr/src/normalize.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { INVALID } from './denormalize/symbol.js'; -import type { - EntityInterface, - Schema, - NormalizedIndex, - GetEntity, -} from './interface.js'; -import { createGetEntity } from './memo/MemoCache.js'; -import { normalize as arrayNormalize } from './schemas/Array.js'; -import { normalize as objectNormalize } from './schemas/Object.js'; -import type { NormalizeNullable, NormalizedSchema } from './types.js'; - -const getVisit = ( - addEntity: ( - schema: EntityInterface, - processedEntity: any, - id: string, - ) => void, - getEntity: GetEntity, -) => { - const checkLoop = getCheckLoop(); - const visit = ( - schema: any, - value: any, - parent: any, - key: any, - args: readonly any[], - ) => { - if (!value || !schema) { - return value; - } - - if (schema.normalize && typeof schema.normalize === 'function') { - if (typeof value !== 'object') { - if (schema.pk) return `${value}`; - return value; - } - return schema.normalize( - value, - parent, - key, - args, - visit, - addEntity, - getEntity, - checkLoop, - ); - } - - if (typeof value !== 'object' || typeof schema !== 'object') return value; - - const method = Array.isArray(schema) ? arrayNormalize : objectNormalize; - return method( - schema, - value, - parent, - key, - args, - visit, - addEntity, - getEntity, - checkLoop, - ); - }; - return visit; -}; - -const addEntities = - ( - newEntities: Record, - newIndexes: Record, - entitiesCopy: Record, - indexesCopy: Record, - entityMetaCopy: { - [entityKey: string]: { - [pk: string]: { - date: number; - expiresAt: number; - fetchedAt: number; - }; - }; - }, - actionMeta: { fetchedAt: number; date: number; expiresAt: number }, - ) => - (schema: EntityInterface, processedEntity: any, id: string) => { - const schemaKey = schema.key; - // first time we come across this type of entity - if (!(schemaKey in newEntities)) { - newEntities[schemaKey] = {}; - // we will be editing these, so we need to clone them first - entitiesCopy[schemaKey] = { ...entitiesCopy[schemaKey] }; - entityMetaCopy[schemaKey] = { ...entityMetaCopy[schemaKey] }; - } - - const existingEntity = newEntities[schemaKey][id]; - if (existingEntity) { - newEntities[schemaKey][id] = schema.merge( - existingEntity, - processedEntity, - ); - } else { - const inStoreEntity = entitiesCopy[schemaKey][id]; - let inStoreMeta: { - date: number; - expiresAt: number; - fetchedAt: number; - }; - // this case we already have this entity in store - if (inStoreEntity && (inStoreMeta = entityMetaCopy[schemaKey][id])) { - newEntities[schemaKey][id] = schema.mergeWithStore( - inStoreMeta, - actionMeta, - inStoreEntity, - processedEntity, - ); - entityMetaCopy[schemaKey][id] = schema.mergeMetaWithStore( - inStoreMeta, - actionMeta, - inStoreEntity, - processedEntity, - ); - } else { - newEntities[schemaKey][id] = processedEntity; - entityMetaCopy[schemaKey][id] = actionMeta; - } - } - - // update index - if (schema.indexes) { - if (!(schemaKey in newIndexes)) { - newIndexes[schemaKey] = {}; - indexesCopy[schemaKey] = { ...indexesCopy[schemaKey] }; - } - handleIndexes( - id, - schema.indexes, - newIndexes[schemaKey], - indexesCopy[schemaKey], - newEntities[schemaKey][id], - entitiesCopy[schemaKey], - ); - } - // set this after index updates so we know what indexes to remove from - entitiesCopy[schemaKey][id] = newEntities[schemaKey][id]; - }; - -function handleIndexes( - id: string, - schemaIndexes: string[], - indexes: Record, - storeIndexes: Record, - entity: any, - storeEntities: Record, -) { - for (const index of schemaIndexes) { - if (!(index in indexes)) { - storeIndexes[index] = indexes[index] = {}; - } - const indexMap = indexes[index]; - if (storeEntities[id]) { - delete indexMap[storeEntities[id][index]]; - } - // entity already in cache but the index changed - if ( - storeEntities && - storeEntities[id] && - storeEntities[id][index] !== entity[index] - ) { - indexMap[storeEntities[id][index]] = INVALID; - } - if (index in entity) { - indexMap[entity[index]] = id; - } /* istanbul ignore next */ else if ( - // eslint-disable-next-line no-undef - process.env.NODE_ENV !== 'production' - ) { - console.warn(`Index not found in entity. Indexes must be top-level members of your entity. -Index: ${index} -Entity: ${JSON.stringify(entity, undefined, 2)}`); - } - } -} - -function expectedSchemaType(schema: Schema) { - return ['object', 'function'].includes(typeof schema) ? 'object' : ( - typeof schema - ); -} - -function getCheckLoop() { - const visitedEntities = {}; - /* Returns true if a circular reference is found */ - return function checkLoop(entityKey: string, pk: string, input: object) { - if (!(entityKey in visitedEntities)) { - visitedEntities[entityKey] = {}; - } - if (!(pk in visitedEntities[entityKey])) { - visitedEntities[entityKey][pk] = []; - } - if ( - visitedEntities[entityKey][pk].some((entity: any) => entity === input) - ) { - return true; - } - visitedEntities[entityKey][pk].push(input); - return false; - }; -} - -interface StoreData { - entities: Readonly; - indexes: Readonly; - entityMeta: { - readonly [entityKey: string]: { - readonly [pk: string]: { - readonly fetchedAt: number; - readonly date: number; - readonly expiresAt: number; - }; - }; - }; -} -const emptyStore: StoreData = { - entities: {}, - indexes: {}, - entityMeta: {}, -}; -interface NormalizeMeta { - expiresAt: number; - date: number; - fetchedAt: number; -} -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const normalize = < - S extends Schema = Schema, - E extends Record | undefined> = Record< - string, - Record - >, - R = NormalizeNullable, ->( - schema: S | undefined, - input: any, - args: readonly any[] = [], - { entities, indexes, entityMeta }: StoreData = emptyStore, - meta: NormalizeMeta = { fetchedAt: 0, date: Date.now(), expiresAt: Infinity }, -): NormalizedSchema => { - // no schema means we don't process at all - if (schema === undefined || schema === null) - return { - result: input, - entities, - indexes, - entityMeta, - }; - - const schemaType = expectedSchemaType(schema); - if ( - input === null || - (typeof input !== schemaType && - // we will allow a Delete schema to be a string or object - !( - (schema as any).key !== undefined && - (schema as any).pk === undefined && - typeof input === 'string' - )) - ) { - /* istanbul ignore else */ - if (process.env.NODE_ENV !== 'production') { - const parseWorks = (input: string) => { - try { - return typeof JSON.parse(input) !== 'string'; - } catch (e) { - return false; - } - }; - if (typeof input === 'string' && parseWorks(input)) { - throw new Error(`Normalizing a string, but this does match schema. - -Parsing this input string as JSON worked. This likely indicates fetch function did not parse -the JSON. By default, this only happens if "content-type" header includes "json". -See https://dataclient.io/rest/api/RestEndpoint#parseResponse for more information - - Schema: ${JSON.stringify(schema, undefined, 2)} - Input: "${input}"`); - } else { - throw new Error( - `Unexpected input given to normalize. Expected type to be "${schemaType}", found "${ - input === null ? 'null' : typeof input - }". - - Schema: ${JSON.stringify(schema, undefined, 2)} - Input: "${input}"`, - ); - } - } else { - throw new Error( - `Unexpected input given to normalize. Expected type to be "${schemaType}", found "${ - input === null ? 'null' : typeof input - }".`, - ); - } - } - - const newEntities: E = {} as any; - const newIndexes: NormalizedIndex = {} as any; - const ret: NormalizedSchema = { - result: '' as any, - entities: { ...entities }, - indexes: { ...indexes }, - entityMeta: { ...entityMeta }, - }; - const addEntity = addEntities( - newEntities, - newIndexes, - ret.entities, - ret.indexes, - ret.entityMeta, - meta, - ); - - const visit = getVisit(addEntity, createGetEntity(entities)); - ret.result = visit(schema, input, input, undefined, args); - return ret; -}; diff --git a/packages/normalizr/src/normalizr/addEntities.ts b/packages/normalizr/src/normalizr/addEntities.ts new file mode 100644 index 000000000000..619aeeab6b50 --- /dev/null +++ b/packages/normalizr/src/normalizr/addEntities.ts @@ -0,0 +1,118 @@ +import { INVALID } from '../denormalize/symbol.js'; +import type { EntityInterface } from '../interface.js'; + +export const addEntities = + ( + newEntities: Record, + newIndexes: Record, + entitiesCopy: Record, + indexesCopy: Record, + entityMetaCopy: { + [entityKey: string]: { + [pk: string]: { + date: number; + expiresAt: number; + fetchedAt: number; + }; + }; + }, + actionMeta: { fetchedAt: number; date: number; expiresAt: number }, + ) => + (schema: EntityInterface, processedEntity: any, id: string) => { + const schemaKey = schema.key; + // first time we come across this type of entity + if (!(schemaKey in newEntities)) { + newEntities[schemaKey] = {}; + // we will be editing these, so we need to clone them first + entitiesCopy[schemaKey] = { ...entitiesCopy[schemaKey] }; + entityMetaCopy[schemaKey] = { ...entityMetaCopy[schemaKey] }; + } + + const existingEntity = newEntities[schemaKey][id]; + if (existingEntity) { + newEntities[schemaKey][id] = schema.merge( + existingEntity, + processedEntity, + ); + } else { + const inStoreEntity = entitiesCopy[schemaKey][id]; + let inStoreMeta: { + date: number; + expiresAt: number; + fetchedAt: number; + }; + // this case we already have this entity in store + if (inStoreEntity && (inStoreMeta = entityMetaCopy[schemaKey][id])) { + newEntities[schemaKey][id] = schema.mergeWithStore( + inStoreMeta, + actionMeta, + inStoreEntity, + processedEntity, + ); + entityMetaCopy[schemaKey][id] = schema.mergeMetaWithStore( + inStoreMeta, + actionMeta, + inStoreEntity, + processedEntity, + ); + } else { + newEntities[schemaKey][id] = processedEntity; + entityMetaCopy[schemaKey][id] = actionMeta; + } + } + + // update index + if (schema.indexes) { + if (!(schemaKey in newIndexes)) { + newIndexes[schemaKey] = {}; + indexesCopy[schemaKey] = { ...indexesCopy[schemaKey] }; + } + handleIndexes( + id, + schema.indexes, + newIndexes[schemaKey], + indexesCopy[schemaKey], + newEntities[schemaKey][id], + entitiesCopy[schemaKey], + ); + } + // set this after index updates so we know what indexes to remove from + entitiesCopy[schemaKey][id] = newEntities[schemaKey][id]; + }; + +function handleIndexes( + id: string, + schemaIndexes: string[], + indexes: Record, + storeIndexes: Record, + entity: any, + storeEntities: Record, +) { + for (const index of schemaIndexes) { + if (!(index in indexes)) { + storeIndexes[index] = indexes[index] = {}; + } + const indexMap = indexes[index]; + if (storeEntities[id]) { + delete indexMap[storeEntities[id][index]]; + } + // entity already in cache but the index changed + if ( + storeEntities && + storeEntities[id] && + storeEntities[id][index] !== entity[index] + ) { + indexMap[storeEntities[id][index]] = INVALID; + } + if (index in entity) { + indexMap[entity[index]] = id; + } /* istanbul ignore next */ else if ( + // eslint-disable-next-line no-undef + process.env.NODE_ENV !== 'production' + ) { + console.warn(`Index not found in entity. Indexes must be top-level members of your entity. +Index: ${index} +Entity: ${JSON.stringify(entity, undefined, 2)}`); + } + } +} diff --git a/packages/normalizr/src/normalizr/getCheckLoop.ts b/packages/normalizr/src/normalizr/getCheckLoop.ts new file mode 100644 index 000000000000..a13b61f8654c --- /dev/null +++ b/packages/normalizr/src/normalizr/getCheckLoop.ts @@ -0,0 +1,19 @@ +export function getCheckLoop() { + const visitedEntities = {}; + /* Returns true if a circular reference is found */ + return function checkLoop(entityKey: string, pk: string, input: object) { + if (!(entityKey in visitedEntities)) { + visitedEntities[entityKey] = {}; + } + if (!(pk in visitedEntities[entityKey])) { + visitedEntities[entityKey][pk] = []; + } + if ( + visitedEntities[entityKey][pk].some((entity: any) => entity === input) + ) { + return true; + } + visitedEntities[entityKey][pk].push(input); + return false; + }; +} diff --git a/packages/normalizr/src/normalizr/getVisit.ts b/packages/normalizr/src/normalizr/getVisit.ts new file mode 100644 index 000000000000..2cf190f06660 --- /dev/null +++ b/packages/normalizr/src/normalizr/getVisit.ts @@ -0,0 +1,59 @@ +import { getCheckLoop } from './getCheckLoop.js'; +import type { EntityInterface, GetEntity } from '../interface.js'; +import { normalize as arrayNormalize } from '../schemas/Array.js'; +import { normalize as objectNormalize } from '../schemas/Object.js'; + +export const getVisit = ( + addEntity: ( + schema: EntityInterface, + processedEntity: any, + id: string, + ) => void, + getEntity: GetEntity, +) => { + const checkLoop = getCheckLoop(); + const visit = ( + schema: any, + value: any, + parent: any, + key: any, + args: readonly any[], + ) => { + if (!value || !schema) { + return value; + } + + if (schema.normalize && typeof schema.normalize === 'function') { + if (typeof value !== 'object') { + if (schema.pk) return `${value}`; + return value; + } + return schema.normalize( + value, + parent, + key, + args, + visit, + addEntity, + getEntity, + checkLoop, + ); + } + + if (typeof value !== 'object' || typeof schema !== 'object') return value; + + const method = Array.isArray(schema) ? arrayNormalize : objectNormalize; + return method( + schema, + value, + parent, + key, + args, + visit, + addEntity, + getEntity, + checkLoop, + ); + }; + return visit; +}; diff --git a/packages/normalizr/src/normalizr/normalize.ts b/packages/normalizr/src/normalizr/normalize.ts new file mode 100644 index 000000000000..11efd0633e5f --- /dev/null +++ b/packages/normalizr/src/normalizr/normalize.ts @@ -0,0 +1,131 @@ +import { addEntities } from './addEntities.js'; +import { getVisit } from './getVisit.js'; +import type { Schema, NormalizedIndex } from '../interface.js'; +import { createGetEntity } from '../memo/MemoCache.js'; +import type { NormalizeNullable, NormalizedSchema } from '../types.js'; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const normalize = < + S extends Schema = Schema, + E extends Record | undefined> = Record< + string, + Record + >, + R = NormalizeNullable, +>( + schema: S | undefined, + input: any, + args: readonly any[] = [], + { entities, indexes, entityMeta }: StoreData = emptyStore, + meta: NormalizeMeta = { fetchedAt: 0, date: Date.now(), expiresAt: Infinity }, +): NormalizedSchema => { + // no schema means we don't process at all + if (schema === undefined || schema === null) + return { + result: input, + entities, + indexes, + entityMeta, + }; + + const schemaType = expectedSchemaType(schema); + if ( + input === null || + (typeof input !== schemaType && + // we will allow a Delete schema to be a string or object + !( + (schema as any).key !== undefined && + (schema as any).pk === undefined && + typeof input === 'string' + )) + ) { + /* istanbul ignore else */ + if (process.env.NODE_ENV !== 'production') { + const parseWorks = (input: string) => { + try { + return typeof JSON.parse(input) !== 'string'; + } catch (e) { + return false; + } + }; + if (typeof input === 'string' && parseWorks(input)) { + throw new Error(`Normalizing a string, but this does match schema. + +Parsing this input string as JSON worked. This likely indicates fetch function did not parse +the JSON. By default, this only happens if "content-type" header includes "json". +See https://dataclient.io/rest/api/RestEndpoint#parseResponse for more information + + Schema: ${JSON.stringify(schema, undefined, 2)} + Input: "${input}"`); + } else { + throw new Error( + `Unexpected input given to normalize. Expected type to be "${schemaType}", found "${ + input === null ? 'null' : typeof input + }". + + Schema: ${JSON.stringify(schema, undefined, 2)} + Input: "${input}"`, + ); + } + } else { + throw new Error( + `Unexpected input given to normalize. Expected type to be "${schemaType}", found "${ + input === null ? 'null' : typeof input + }".`, + ); + } + } + + const newEntities: E = {} as any; + const newIndexes: NormalizedIndex = {} as any; + const ret: NormalizedSchema = { + result: '' as any, + entities: { ...entities }, + indexes: { ...indexes }, + entityMeta: { ...entityMeta }, + }; + const addEntity = addEntities( + newEntities, + newIndexes, + ret.entities, + ret.indexes, + ret.entityMeta, + meta, + ); + + const visit = getVisit(addEntity, createGetEntity(entities)); + ret.result = visit(schema, input, input, undefined, args); + return ret; +}; + +function expectedSchemaType(schema: Schema) { + return ['object', 'function'].includes(typeof schema) ? 'object' : ( + typeof schema + ); +} + +const emptyStore: StoreData = { + entities: {}, + indexes: {}, + entityMeta: {}, +}; + +interface StoreData { + entities: Readonly; + indexes: Readonly; + entityMeta: { + readonly [entityKey: string]: { + readonly [pk: string]: { + readonly fetchedAt: number; + readonly date: number; + readonly expiresAt: number; + }; + }; + }; +} + +interface NormalizeMeta { + expiresAt: number; + date: number; + fetchedAt: number; +}