Skip to content

Commit

Permalink
feat: New Entity that normalizes to pojos
Browse files Browse the repository at this point in the history
  • Loading branch information
ntucker committed May 16, 2021
1 parent 659ccda commit 830d269
Show file tree
Hide file tree
Showing 8 changed files with 2,420 additions and 0 deletions.
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const baseConfig = {
'node_modules',
'react-integration/hooks/useSelection',
'packages/test',
'packages/experimental',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
moduleNameMapper: {
Expand Down
356 changes: 356 additions & 0 deletions packages/experimental/src/entity/Entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { schema } from '@rest-hooks/endpoint';
import type { AbstractInstanceType, Schema } from '@rest-hooks/endpoint';

import { isImmutable, denormalizeImmutable } from './ImmutableUtils';

/** Represents data that should be deduped by specifying a primary key. */
export default abstract class Entity {
static toJSON() {
return {
name: this.name,
schema: this.schema,
key: this.key,
};
}

/** Defines nested entities */
static schema: { [k: string]: Schema } = {};

/**
* A unique identifier for each Entity
*
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
*/
abstract pk(parent?: any, key?: string): string | undefined;

/** Returns the globally unique identifier for the static Entity */
static get key(): string {
/* istanbul ignore next */
if (
process.env.NODE_ENV !== 'production' &&
(this.name === '' || this.name === 'Entity')
)
throw new Error(
'Entity classes without a name must define `static get key()`',
);
return this.name;
}

/** Defines indexes to enable lookup by */
declare static indexes?: readonly string[];

/** Control how automatic schema validation is handled
*
* `undefined`: Defaults - throw error in worst offense
* 'warn': only ever warn
* 'silent': Don't bother with processing at all
*
* Note: this only applies to non-nested members.
*/
protected declare static automaticValidation?: 'warn' | 'silent';

/**
* A unique identifier for each Entity
*
* @param [value] POJO of the entity or subset used
* @param [parent] When normalizing, the object which included the entity
* @param [key] When normalizing, the key where this entity was found
*/
static pk<T extends typeof Entity>(
this: T,
value: Partial<AbstractInstanceType<T>>,
parent?: any,
key?: string,
): string | undefined {
return this.prototype.pk.call(value, parent, key) || key;
}

/** Creates new instance copying over defined values of arguments */
static merge<T extends typeof Entity>(
this: T,
existing: Partial<AbstractInstanceType<T>>,
incoming: Partial<AbstractInstanceType<T>>,
) {
return Object.assign(existing, incoming);
}

/** Factory method to convert from Plain JS Objects.
*
* @param [props] Plain Object of properties to assign.
* @param [parent] When normalizing, the object which included the record
* @param [key] When normalizing, the key where this record was found
*/
static fromJS<T extends typeof Entity>(
this: T,
// TODO: this should only accept members that are not functions
props: Partial<AbstractInstanceType<T>> = {},
): AbstractInstanceType<T> {
// we type guarded abstract case above, so ok to force typescript to allow constructor call
const instance = new (this as any)(props) as AbstractInstanceType<T>;
// we can't rely on constructors and override the defaults provided as property assignments
// all occur after the constructor
Object.assign(instance, props);
return instance;
}

/** Do any transformations when first receiving input */
static process(input: any, parent: any, key: string | undefined): any {
return input;
}

static normalize(
input: any,
parent: any,
key: string | undefined,
visit: (...args: any) => any,
addEntity: (...args: any) => any,
visitedEntities: Record<string, any>,
): any {
// pass over already processed entities
if (typeof input === 'string') return input;
const processedEntity = this.process(input, parent, key);
/* istanbul ignore else */
if (
process.env.NODE_ENV !== 'production' &&
this.automaticValidation !== 'silent'
) {
const instanceSample = new (this as any)();
const keysOfRecord = new Set(Object.keys(instanceSample));
const keysOfProps = Object.keys(processedEntity);
const [found, missing, unexpected] = [[], [], []] as [
string[],
string[],
string[],
];
for (const keyOfProps of keysOfProps) {
if (keysOfRecord.has(keyOfProps)) {
found.push(keyOfProps);
} else {
unexpected.push(keyOfProps);
}
}
for (const keyOfRecord of keysOfRecord) {
if (!found.includes(keyOfRecord)) {
missing.push(keyOfRecord);
}
}

// only bother with this if they used *any* defaults
if (keysOfRecord.size) {
if (Array.isArray(processedEntity) && unexpected.length) {
const errorMessage = `Attempted to initialize ${
this.name
} with an array, but named members were expected
This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://resthooks.io/docs/guides/debugging
Learn more about schemas: https://resthooks.io/docs/api/schema
If this is a mistake, you can disable this check by setting static automaticValidation = 'silent'
Missing: ${missing}
First three members: ${JSON.stringify(processedEntity.slice(0, 3), null, 2)}`;
if (this.automaticValidation !== 'warn') {
const error = new Error(errorMessage);
(error as any).status = 400;
throw error;
}
console.warn(errorMessage);
}

const tooManyUnexpected =
// unexpected compared to members in response
Math.max(keysOfProps.length / 2, 1) <= unexpected.length &&
// unexpected compared to what we specified
keysOfRecord.size > Math.max(unexpected.length, 2) &&
// as we find more and more be more easily assured it is correct
found.length ** 1.5 / 2 <= unexpected.length;
const foundNothing = found.length < Math.min(1, keysOfRecord.size / 2);
// if we find nothing (we expect at least one member for a pk)
// or we find too many unexpected members
if (tooManyUnexpected || foundNothing) {
let extra = '';
let reason = 'substantially different than expected keys';
if (foundNothing) {
extra += `\n Missing: ${missing}`;
reason = 'no matching keys found';
}
if (tooManyUnexpected) {
extra += `\n Unexpected keys: ${unexpected}`;
reason = 'a large number of unexpected keys found';
}
const errorMessage = `Attempted to initialize ${
this.name
} with ${reason}
This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://resthooks.io/docs/guides/debugging
Learn more about schemas: https://resthooks.io/docs/api/schema
If this is a mistake, you can disable this check by setting static automaticValidation = 'silent'
Expected keys:
Found: ${found}${extra}
Value (processed): ${JSON.stringify(processedEntity, null, 2)}`;
if (
(found.length >= 4 && tooManyUnexpected) ||
this.automaticValidation === 'warn'
) {
console.warn(errorMessage);
} else {
const error = new Error(errorMessage);
(error as any).status = 400;
throw error;
}
}
}
}
const id = this.pk(processedEntity, parent, key);
if (id === undefined || id === '') {
if (process.env.NODE_ENV !== 'production') {
const error = new Error(
`Missing usable primary key when normalizing response.
This is likely due to a malformed response.
Try inspecting the network response or fetch() return value.
Or use debugging tools: https://resthooks.io/docs/guides/debugging
Learn more about schemas: https://resthooks.io/docs/api/schema
Entity: ${this.name}
Value (processed): ${
processedEntity && JSON.stringify(processedEntity, null, 2)
}
`,
);
(error as any).status = 400;
throw error;
} else {
// these make the keys get deleted
return undefined;
}
}
const entityType = this.key;

if (!(entityType in visitedEntities)) {
visitedEntities[entityType] = {};
}
if (!(id in visitedEntities[entityType])) {
visitedEntities[entityType][id] = [];
}
if (
visitedEntities[entityType][id].some((entity: any) => entity === input)
) {
return id;
}
visitedEntities[entityType][id].push(input);

Object.keys(this.schema).forEach(key => {
if (Object.hasOwnProperty.call(processedEntity, key)) {
const schema = this.schema[key];
processedEntity[key] = visit(
processedEntity[key],
processedEntity,
key,
schema,
addEntity,
visitedEntities,
);
} else if (process.env.NODE_ENV !== 'production') {
const instanceSample = new (this as any)();
if (!Object.hasOwnProperty.call(instanceSample, key)) {
const error = new Error(
`Schema key is missing in Entity
Be sure all schema members are also part of the entity
Or use debugging tools: https://resthooks.io/docs/guides/debugging
Learn more about nesting schemas: https://resthooks.io/docs/guides/nested-response
Entity keys: ${Object.keys(instanceSample)}
Schema key(missing): ${key}
`,
);
(error as any).status = 400;
throw error;
}
}
});

addEntity(this, processedEntity, id);
return id;
}

static denormalize<T extends typeof Entity>(
this: T,
input: Readonly<Partial<AbstractInstanceType<T>>>,
unvisit: schema.UnvisitFunction,
): [AbstractInstanceType<T>, boolean, boolean] {
if (isImmutable(input)) {
// Need to set this first so that if it is referenced further within the
// denormalization the reference will already exist.
unvisit.setLocal?.(input);
const [denormEntity, found, deleted] = denormalizeImmutable(
this.schema,
input,
unvisit,
);
return [this.fromJS(denormEntity.toObject()), found, deleted];
}
const entityCopy: any = this.fromJS(input);
// Need to set this first so that if it is referenced further within the
// denormalization the reference will already exist.
unvisit.setLocal?.(entityCopy);

// TODO: This creates unneeded memory pressure
const instance = new (this as any)();
let deleted = false;

// note: iteration order must be stable
Object.keys(this.schema).forEach(key => {
const schema = this.schema[key];
const nextInput = Object.hasOwnProperty.call(input, key)
? (input as any)[key]
: undefined;
const [value, , deletedItem] = unvisit(nextInput, schema);

if (
deletedItem &&
!(Object.hasOwnProperty.call(input, key) && !instance[key])
) {
deleted = true;
}
if (
Object.hasOwnProperty.call(input, key) &&
(input as any)[key] !== value
) {
this.set(entityCopy, key, value);
}
});

return [entityCopy, true, deleted];
}

/** Used by denormalize to set nested members */
protected static set(entity: any, key: string, value: any) {
entity[key] = value;
}
}

if (process.env.NODE_ENV !== 'production') {
const superFrom = Entity.fromJS;
// for those not using TypeScript this is a good catch to ensure they are defining
// the abstract members
Entity.fromJS = function fromJS<T extends typeof Entity>(
this: T,
props: Partial<AbstractInstanceType<T>>,
): AbstractInstanceType<T> {
if ((this as any).prototype.pk === undefined)
throw new Error('cannot construct on abstract types');
return superFrom.call(this, props) as any;
};
}

export function isEntity(schema: Schema): schema is typeof Entity {
return schema !== null && (schema as any).pk !== undefined;
}

1 comment on commit 830d269

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Benchmark suite Current: 830d269 Previous: b86d103 Ratio
normalizeLong 94.86 ops/sec (±1.36%) 72.36 ops/sec (±1.89%) 0.76
denormalizeLong 35.98 ops/sec (±1.94%) 26.32 ops/sec (±2.03%) 0.73

This comment was automatically generated by workflow using github-action-benchmark.

Please sign in to comment.