From 9d84892cff8a5c3f4dde2220c31c14d15335b521 Mon Sep 17 00:00:00 2001 From: Allan Hortle Date: Sat, 14 Oct 2023 20:09:09 +1100 Subject: [PATCH] Typed schemas --- packages/enty/.eslintrc.js | 3 +- packages/enty/package.json | 3 +- packages/enty/src/ArraySchema.ts | 37 +++++------ packages/enty/src/EntitySchema.ts | 66 +++++++++---------- packages/enty/src/ObjectSchema.ts | 53 +++++++-------- .../enty/src/__tests__/ArraySchema-test.ts | 19 ------ .../enty/src/__tests__/EntitySchema-test.ts | 44 +++++-------- .../enty/src/__tests__/ObjectSchema-test.ts | 30 +-------- packages/enty/src/index.ts | 6 +- packages/enty/src/util/definitions.ts | 54 ++++++--------- packages/enty/yarn.lock | 13 ++-- 11 files changed, 117 insertions(+), 211 deletions(-) diff --git a/packages/enty/.eslintrc.js b/packages/enty/.eslintrc.js index 797555a3..eefd3e7e 100644 --- a/packages/enty/.eslintrc.js +++ b/packages/enty/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { rules: { - 'prettier/prettier': 0 + 'prettier/prettier': 0, + eqeqeq: 0 } }; diff --git a/packages/enty/package.json b/packages/enty/package.json index abb4dcd6..6bbece2b 100644 --- a/packages/enty/package.json +++ b/packages/enty/package.json @@ -48,5 +48,6 @@ "tsdx": "^0.14.1", "tslib": "^2.2.0", "typescript": "^4.3.2" - } + }, + "dependencies": {} } diff --git a/packages/enty/src/ArraySchema.ts b/packages/enty/src/ArraySchema.ts index 9a100c99..780a1ded 100644 --- a/packages/enty/src/ArraySchema.ts +++ b/packages/enty/src/ArraySchema.ts @@ -1,48 +1,45 @@ import {NormalizeState} from './util/definitions'; import {DenormalizeState} from './util/definitions'; -import {Create} from './util/definitions'; -import {StructuralSchemaInterface} from './util/definitions'; -import {Merge} from './util/definitions'; -import {StructuralSchemaOptions} from './util/definitions'; +import {Structure} from './util/definitions'; +import {StructureOptions} from './util/definitions'; import {Schema} from './util/definitions'; import {Entities} from './util/definitions'; - import REMOVED_ENTITY from './util/RemovedEntity'; -export default class ArraySchema implements StructuralSchemaInterface { - shape: A; - create: Create; - merge: Merge; +export default class ArraySchema> implements Structure { + shape: Schema; + create: (next: T) => T; + merge: (previous: T, next: T) => T; - constructor(shape: A, options: StructuralSchemaOptions = {}) { + constructor(shape: Schema, options: StructureOptions = {}) { this.shape = shape; this.merge = options.merge || ((_, bb) => bb); this.create = options.create || ((aa) => aa); } - normalize(data: any, entities: Entities = {}): NormalizeState { + normalize(data: T, entities: Entities = {}): NormalizeState { let schemas = {}; + const result = data.map((item: any): any => { const {result, schemas: childSchemas} = this.shape.normalize(item, entities); Object.assign(schemas, childSchemas); return result; }); - return {entities, schemas, result: this.create(result)}; + return {entities, schemas, result: this.create(result as T)}; } - denormalize(denormalizeState: DenormalizeState, path: Array = []): any { + denormalize(denormalizeState: DenormalizeState, path: Array = []): T | null { const {result, entities} = denormalizeState; - // Filter out any deleted keys - if (result == null) { - return result; - } - // Map denormalize to our result List. + if (result == null) return result; + return result - .map((item: any): any => { + .map((item: unknown) => { return this.shape.denormalize({result: item, entities}, path); }) - .filter((ii: any) => ii !== REMOVED_ENTITY); + .filter((ii: unknown) => { + return ii !== REMOVED_ENTITY; + }); } } diff --git a/packages/enty/src/EntitySchema.ts b/packages/enty/src/EntitySchema.ts index 30649c06..94443cb8 100644 --- a/packages/enty/src/EntitySchema.ts +++ b/packages/enty/src/EntitySchema.ts @@ -1,64 +1,57 @@ import {NormalizeState} from './util/definitions'; import {DenormalizeState} from './util/definitions'; -import {EntitySchemaOptions} from './util/definitions'; -import {EntitySchemaInterface} from './util/definitions'; -import {StructuralSchemaInterface} from './util/definitions'; -import {IdAttribute} from './util/definitions'; -import {Merge} from './util/definitions'; +import {Structure} from './util/definitions'; import {Entities} from './util/definitions'; import {UndefinedIdError} from './util/Error'; import REMOVED_ENTITY from './util/RemovedEntity'; -import ObjectSchema from './ObjectSchema'; -export default class EntitySchema> - implements EntitySchemaInterface { +type Options = { + shape?: Structure; + id?: (entity: T) => string; + merge?: (previous: T, next: T) => T; +}; + +export default class EntitySchema { name: string; - shape: A | null; - id: IdAttribute; - merge: Merge | null | undefined; + shape: Structure | null; + id: (value: T) => string; + merge?: (previous: T, next: T) => T; - constructor(name: string, options: EntitySchemaOptions = {}) { + constructor(name: string, options: Options = {}) { this.name = name; this.merge = options.merge; - - if (options.shape === null) { - this.shape = null; - this.id = options.id || (data => '' + data); - } else { - this.shape = options.shape || new ObjectSchema({}); - this.id = options.id || ((data: any) => data?.id); - } + this.shape = options.shape ?? null; + this.id = options.id || ((data: any) => data?.id); } - normalize(data: unknown, entities: Entities = {}): NormalizeState { + normalize(data: T, entities: Entities = {}): NormalizeState { const {shape, name} = this; let id = this.id(data); - let previousEntity; + let previousEntity: T | null = null; let schemas: Record = {}; - let result; + let result: T; - if (id == null) { - throw UndefinedIdError(name, id); - } + if (id == null) throw UndefinedIdError(name, id); id = id.toString(); entities[name] = entities[name] || {}; - // only normalize if we have a defined shape - if (shape == null) { - result = data; - } else { + // only recurse if we have a defined shape + if (shape) { let _ = shape.normalize(data, entities); result = _.result; schemas = _.schemas; - previousEntity = entities[name][id]; + previousEntity = entities[name][id] as T; + } else { + result = data; } // list this schema as one that has been used schemas[name] = this; + // Store the entity entities[name][id] = previousEntity && shape ? (this.merge || shape.merge)(previousEntity, result) : result; @@ -69,14 +62,15 @@ export default class EntitySchema> }; } - denormalize(denormalizeState: DenormalizeState, path: Array = []): any { + denormalize( + denormalizeState: DenormalizeState, + path: Array = [] + ): T | typeof REMOVED_ENTITY | null { const {result, entities} = denormalizeState; const {shape, name} = this; - const entity = entities?.[name]?.[result]; + const entity = entities[name]?.[result] as T | typeof REMOVED_ENTITY; - if (entity == null || entity === REMOVED_ENTITY || shape == null) { - return entity; - } + if (entity == null || entity === REMOVED_ENTITY || shape === null) return entity ?? null; return shape.denormalize({result: entity, entities}, path); } diff --git a/packages/enty/src/ObjectSchema.ts b/packages/enty/src/ObjectSchema.ts index 612dd2d6..67305c52 100644 --- a/packages/enty/src/ObjectSchema.ts +++ b/packages/enty/src/ObjectSchema.ts @@ -1,37 +1,36 @@ -import {NormalizeState} from './util/definitions'; +import {NormalizeState, Structure} from './util/definitions'; import {DenormalizeState} from './util/definitions'; -import {StructuralSchemaInterface} from './util/definitions'; -import {Create} from './util/definitions'; -import {Merge} from './util/definitions'; import {Entities} from './util/definitions'; import {Schema} from './util/definitions'; -import {StructuralSchemaOptions} from './util/definitions'; - +import {StructureOptions} from './util/definitions'; import REMOVED_ENTITY from './util/RemovedEntity'; -export default class ObjectSchema> - implements StructuralSchemaInterface { - create: Create; - merge: Merge; - shape: A; +export default class ObjectSchema> implements Structure { + create: (value: T) => T; + merge: (previous: T, next: T) => T; + relations: Partial>>; - constructor(shape: A, options: StructuralSchemaOptions = {}) { - this.shape = shape; - this.create = options.create || (item => ({...item})); + constructor( + relations: Partial>>, + options: StructureOptions = {} + ) { + this.relations = relations; + this.create = options.create || ((item) => ({...item})); this.merge = options.merge || ((previous, next) => ({...previous, ...next})); } /** * ObjectSchema.normalize */ - normalize(data: any, entities: Entities = {}): NormalizeState { - const {shape} = this; + normalize(data: T, entities: Entities = {}): NormalizeState { + const {relations} = this; const dataMap = data; let schemas = {}; - const result = Object.keys(shape).reduce((result: Object, key: any): any => { + const result = Object.keys(relations).reduce((result: Object, key: any): any => { const value = dataMap[key]; - const schema = shape[key]; + const schema = relations[key]; + if (!schema) throw new Error(`${String(key)} was not defined in shape`); if (value) { const {result: childResult, schemas: childSchemas} = schema.normalize( value, @@ -42,7 +41,7 @@ export default class ObjectSchema> } return result; - }, dataMap); + }, dataMap) as T; return {entities, schemas, result: this.create(result)}; } @@ -50,9 +49,9 @@ export default class ObjectSchema> /** * ObjectSchema.denormalize */ - denormalize(denormalizeState: DenormalizeState, path: Array = []): any { + denormalize(denormalizeState: DenormalizeState, path: Array = []): T | null { const {result, entities} = denormalizeState; - const {shape} = this; + const {relations} = this; if (result == null || result === REMOVED_ENTITY) { return result; @@ -62,19 +61,17 @@ export default class ObjectSchema> // if they have a corresponding schema. Otherwise return the plain value. // Then filter out deleted keys, keeping track of ones deleted // Then Pump the filtered object through `denormalizeFilter` - let item = {...result}; + let item: T = {...result}; if (path.indexOf(this) !== -1) { return item; } - const keys = Object.keys(shape); - - for (let key of keys) { - const schema = shape[key]; + for (let key in relations) { + const schema = relations[key]; const result = item[key]; - const value = schema.denormalize({result, entities}, [...path, this]); + const value = schema?.denormalize({result, entities}, [...path, this]); - if (value !== REMOVED_ENTITY && value !== undefined) { + if (value !== REMOVED_ENTITY && value != undefined) { item[key] = value; } else { delete item[key]; diff --git a/packages/enty/src/__tests__/ArraySchema-test.ts b/packages/enty/src/__tests__/ArraySchema-test.ts index 0928f728..03c8e17e 100644 --- a/packages/enty/src/__tests__/ArraySchema-test.ts +++ b/packages/enty/src/__tests__/ArraySchema-test.ts @@ -73,25 +73,6 @@ test('ArraySchema will not mutate input objects', () => { expect(arrayTest).toEqual([{id: '1'}]); }); -test('ArraySchemas can construct custom objects', () => { - class Foo { - data: string[]; - constructor(data: string[]) { - this.data = data; - } - map(fn: (x: string) => string) { - this.data = this.data.map(fn); - return this; - } - } - const schema = new ArraySchema(new ObjectSchema({}), { - create: (data) => new Foo(data) - }); - const state = schema.normalize([{foo: 1}, {bar: 2}], {}); - expect(state.result).toBeInstanceOf(Foo); - expect(state.result.data[0]).toEqual({foo: 1}); -}); - it('will default replace array on merge', () => { const foo = new EntitySchema('foo'); const schema = new ArraySchema(foo); diff --git a/packages/enty/src/__tests__/EntitySchema-test.ts b/packages/enty/src/__tests__/EntitySchema-test.ts index 12cbea6f..363ad6c1 100644 --- a/packages/enty/src/__tests__/EntitySchema-test.ts +++ b/packages/enty/src/__tests__/EntitySchema-test.ts @@ -3,27 +3,22 @@ import ArraySchema from '../ArraySchema'; import ObjectSchema from '../ObjectSchema'; import {UndefinedIdError} from '../util/Error'; -var foo = new EntitySchema('foo'); -var bar = new EntitySchema('bar'); -var baz = new EntitySchema('baz'); +var foo = new EntitySchema<{id: string}>('foo'); +var bar = new EntitySchema<{id: string; foo: {id: string}}>('bar'); +var baz = new EntitySchema<{id: string; bar: {id: string}}>('baz'); foo.shape = new ObjectSchema({}); -baz.shape = new ObjectSchema({bar}); -bar.shape = new ObjectSchema({foo}); +bar.shape = new ObjectSchema<{id: string; foo: {id: string}}>({foo}); +baz.shape = new ObjectSchema<{id: string; bar: {id: string}}>({bar}); describe('configuration', () => { it('can mutate its shape', () => { - var schema = new EntitySchema('foo'); + var schema = new EntitySchema<{}>('foo'); const shape = new ObjectSchema({}); schema.shape = shape; expect(schema.shape).toBe(shape); }); - it('will default to a ObjectSchema shape', () => { - let schemaB = new EntitySchema('foo'); - expect(schemaB.shape).toBeInstanceOf(ObjectSchema); - }); - it('can override the shapes merge function', () => { let schema = new EntitySchema('test', { id: () => 'aa', @@ -79,20 +74,11 @@ describe('EntitySchema.normalize', () => { it('will treat null shapes like an Id schema', () => { const NullSchemaEntity = new EntitySchema('foo', { - shape: null, - id: data => `${data}-foo` + id: (data) => `${data}-foo` }); const state = NullSchemaEntity.normalize(2, {}); expect(state.entities.foo['2-foo']).toBe(2); }); - - it('will default id function to stringify if shape is null', () => { - const NullSchemaEntity = new EntitySchema('foo', { - shape: null - }); - const state = NullSchemaEntity.normalize({}, {}); - expect(state.entities.foo['[object Object]']).toEqual({}); - }); }); describe('EntitySchema.denormalize', () => { @@ -107,11 +93,14 @@ describe('EntitySchema.denormalize', () => { }); it('will not cause an infinite recursion', () => { - const foo = new EntitySchema('foo'); - const bar = new EntitySchema('bar'); + type Foo = {id: string; bar: Bar}; + type Bar = {id: string; foo: Foo}; + + const foo = new EntitySchema('foo'); + const bar = new EntitySchema('bar'); - foo.shape = new ObjectSchema({bar}); - bar.shape = new ObjectSchema({foo}); + foo.shape = new ObjectSchema({bar}); + bar.shape = new ObjectSchema({foo}); const entities = { bar: {'1': {id: '1', foo: '1'}}, @@ -135,13 +124,12 @@ describe('EntitySchema.denormalize', () => { bar: {'1': {id: '1', foo: null}} }; - expect(bar.denormalize({result: '2', entities})).toEqual(undefined); + expect(bar.denormalize({result: '2', entities})).toEqual(null); }); it('can denormalize null shapes', () => { const NullSchemaEntity = new EntitySchema('foo', { - shape: null, - id: data => `${data}-foo` + id: (data) => `${data}-foo` }); const state = NullSchemaEntity.normalize(2, {}); expect(NullSchemaEntity.denormalize(state)).toBe(2); diff --git a/packages/enty/src/__tests__/ObjectSchema-test.ts b/packages/enty/src/__tests__/ObjectSchema-test.ts index a9ab577b..e191ff03 100644 --- a/packages/enty/src/__tests__/ObjectSchema-test.ts +++ b/packages/enty/src/__tests__/ObjectSchema-test.ts @@ -30,14 +30,6 @@ test('ObjectSchema.denormalize is the inverse of ObjectSchema.normalize', () => expect(data).toEqual(output); }); -test('ObjectSchema can normalize empty objects', () => { - const schema = new ObjectSchema({foo}); - let {entities, result} = schema.normalize({bar: {}}); - - expect(entities).toEqual({}); - expect(result).toEqual({bar: {}}); -}); - test('ObjectSchema can denormalize objects', () => { const schema = new ObjectSchema({foo}); @@ -113,28 +105,8 @@ test('ObjectSchema will not mutate input objects', () => { expect(objectTest).toEqual({foo: {id: '1'}}); }); -test('ObjectSchemas can create objects', () => { - class Foo { - first: string; - last: string; - constructor(data: {first: string; last: string}) { - this.first = data.first; - this.last = data.last; - } - } - const schema = new ObjectSchema( - {}, - { - create: (data) => new Foo(data) - } - ); - const state = schema.normalize({first: 'foo', last: 'bar'}, {}); - - expect(state.result).toBeInstanceOf(Foo); -}); - it('will not create extra keys if value is undefined', () => { - const schema = new ObjectSchema({ + const schema = new ObjectSchema<{foo: {id: string}; bar?: {id: string}}>({ foo: new EntitySchema('foo'), bar: new EntitySchema('bar') }); diff --git a/packages/enty/src/index.ts b/packages/enty/src/index.ts index e6bb8ae9..10c73f51 100644 --- a/packages/enty/src/index.ts +++ b/packages/enty/src/index.ts @@ -10,11 +10,7 @@ export {UndefinedIdError} from './util/Error'; // // Supporting Types - export {NormalizeState} from './util/definitions'; export {DenormalizeState} from './util/definitions'; -export {StructuralSchemaOptions} from './util/definitions'; -export {EntitySchemaOptions} from './util/definitions'; export {Schema} from './util/definitions'; -export {StructuralSchemaInterface} from './util/definitions'; -export {EntitySchemaInterface} from './util/definitions'; +export {Structure} from './util/definitions'; diff --git a/packages/enty/src/util/definitions.ts b/packages/enty/src/util/definitions.ts index f14ca6a8..4300144a 100644 --- a/packages/enty/src/util/definitions.ts +++ b/packages/enty/src/util/definitions.ts @@ -1,7 +1,9 @@ +export type Entities = Record>; + export type NormalizeState = { entities: Entities; result: any; - schemas: Record; + schemas: Record>; }; export type DenormalizeState = { @@ -9,48 +11,30 @@ export type DenormalizeState = { result: any; }; -export type StructuralSchemaOptions = { - create?: Create; - merge?: Merge; -}; - -export type EntitySchemaOptions = { - readonly shape?: Shape; - id?: (entity: any) => string; - merge?: Merge; -}; - // // Options -export type Entities = Record>; -export type Normalize = (data: unknown, entities: Entities) => NormalizeState; -export type Denormalize = (denormalizeState: DenormalizeState, path?: Array) => any; -export type Create = (data: any) => any; -export type Merge = (previous: any, next: any) => any; -export type IdAttribute = (data: any) => string; - // // Interfaces -export interface EntitySchemaInterface { - readonly normalize: Normalize; - readonly denormalize: Denormalize; - shape: Shape | null; - name: string; - id: (item: unknown) => string; +export interface Schema { + normalize: (data: T, entities: Entities) => NormalizeState; + denormalize: (data: DenormalizeState, path?: Array) => T | null; } -export interface StructuralSchemaInterface { - readonly normalize: Normalize; - readonly denormalize: Denormalize; - shape: Shape; - create: Create; - merge: Merge; +export interface Entity extends Schema { + name: string; + id: (item: T) => string; + shape: Shape | null; } -export interface Schema { - name?: string; - readonly normalize: Normalize; - readonly denormalize: Denormalize; +export interface Structure extends Schema { + /** @deprecated */ + create: (value: T) => T; + merge: (previous: T, next: T) => T; } + +export type StructureOptions = { + create?: (value: T) => T; + merge?: (previous: T, next: T) => T; +}; diff --git a/packages/enty/yarn.lock b/packages/enty/yarn.lock index 2ef44f51..ba37789a 100644 --- a/packages/enty/yarn.lock +++ b/packages/enty/yarn.lock @@ -2737,15 +2737,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0: - version "1.0.30001235" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001235.tgz#ad5ca75bc5a1f7b12df79ad806d715a43a5ac4ed" - integrity sha512-zWEwIVqnzPkSAXOUlQnPW2oKoYb2aLQ4Q5ejdjBcnH63rfypaW34CxaeBn1VMya2XaEU3P/R2qHpWyj+l0BT1A== - -caniuse-lite@^1.0.30001219: - version "1.0.30001228" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001228.tgz#bfdc5942cd3326fa51ee0b42fbef4da9d492a7fa" - integrity sha512-QQmLOGJ3DEgokHbMSA8cj2a+geXqmnpyOFT0lhQV6P3/YOJvGDEwoedcwxEQ30gJIwIIunHIicunJ2rzK5gB2A== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219: + version "1.0.30001549" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001549.tgz" + integrity sha512-qRp48dPYSCYaP+KurZLhDYdVE+yEyht/3NlmcJgVQ2VMGt6JL36ndQ/7rgspdZsJuxDPFIo/OzBT2+GmIJ53BA== capture-exit@^2.0.0: version "2.0.0"