@data-client/normalizr
Version:
Normalizes and denormalizes JSON according to schema for Redux and Flux applications
211 lines (193 loc) • 6.68 kB
text/typescript
import GlobalCache from './globalCache.js';
import { EndpointsCache, EntityCache } from './types.js';
import WeakDependencyMap, { Dep, GetDependency } from './WeakDependencyMap.js';
import buildQueryKey from '../buildQueryKey.js';
import { getEntities } from '../denormalize/getEntities.js';
import getUnvisit from '../denormalize/unvisit.js';
import type { EntityTable, NormalizedIndex, Schema } from '../interface.js';
import { isImmutable } from '../schemas/ImmutableUtils.js';
import type {
DenormalizeNullable,
EntityPath,
NormalizeNullable,
} from '../types.js';
//TODO: make immutable distinction occur when initilizing MemoCache
/** Singleton to store the memoization cache for denormalization methods */
export default class MemoCache {
/** Cache for every entity based on its dependencies and its own input */
protected entities: EntityCache = new Map();
/** Caches the final denormalized form based on input, entities */
protected endpoints: EndpointsCache = new WeakDependencyMap<EntityPath>();
/** Caches the queryKey based on schema, args, and any used entities or indexes */
protected queryKeys: Map<string, WeakDependencyMap<QueryPath>> = new Map();
/** Compute denormalized form maintaining referential equality for same inputs */
denormalize<S extends Schema>(
schema: S | undefined,
input: unknown,
entities: any,
args: readonly any[] = [],
): {
data: DenormalizeNullable<S> | symbol;
paths: EntityPath[];
} {
// we already vary based on input, so we don't need endpointKey? TODO: verify
// if (!this.endpoints[endpointKey])
// this.endpoints[endpointKey] = new WeakDependencyMap<EntityPath>();
// undefined means don't do anything
if (schema === undefined) {
return { data: input as any, paths: [] };
}
if (input === undefined) {
return { data: undefined as any, paths: [] };
}
const getEntity = getEntities(entities);
return getUnvisit(
getEntity,
new GlobalCache(getEntity, this.entities, this.endpoints),
args,
)(schema, input);
}
/** Compute denormalized form maintaining referential equality for same inputs */
query<S extends Schema>(
schema: S,
args: readonly any[],
entities:
| Record<string, Record<string, any> | undefined>
| {
getIn(k: string[]): any;
},
indexes:
| NormalizedIndex
| {
getIn(k: string[]): any;
},
// NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now
argsKey: string = JSON.stringify(args),
): DenormalizeNullable<S> | undefined {
const input = this.buildQueryKey(schema, args, entities, indexes, argsKey);
if (!input) {
return;
}
const { data } = this.denormalize(schema, input, entities, args);
return typeof data === 'symbol' ? undefined : (data as any);
}
buildQueryKey<S extends Schema>(
schema: S,
args: readonly any[],
entities:
| Record<string, Record<string, any> | undefined>
| {
getIn(k: string[]): any;
},
indexes:
| NormalizedIndex
| {
getIn(k: string[]): any;
},
// NOTE: different orders can result in cache busting here; but since it's just a perf penalty we will allow for now
argsKey: string = JSON.stringify(args),
): NormalizeNullable<S> {
// This is redundant for buildQueryKey checks, but that was is used for recursion so we still need the checks there
// TODO: If we make each recursive call include cache lookups, we combine these checks together
// null is object so we need double check
if (
(typeof schema !== 'object' &&
typeof (schema as any).queryKey !== 'function') ||
!schema
)
return schema as any;
// cache lookup: argsKey -> schema -> ...touched indexes or entities
if (!this.queryKeys.get(argsKey)) {
this.queryKeys.set(argsKey, new WeakDependencyMap<QueryPath>());
}
const queryCache = this.queryKeys.get(argsKey) as WeakDependencyMap<
QueryPath,
object,
any
>;
const getEntity = createGetEntity(entities);
const getIndex = createGetIndex(indexes);
// eslint-disable-next-line prefer-const
let [value, paths] = queryCache.get(
schema as any,
createDepLookup(getEntity, getIndex),
);
// paths undefined is the only way to truly tell nothing was found (the value could have actually been undefined)
if (!paths) {
// first dep path is ignored
// we start with schema object, then lookup any 'touched' members and their paths
const dependencies: Dep<QueryPath>[] = [
{ path: [''], entity: schema as any },
];
value = buildQueryKey(
schema,
args,
trackLookup(getEntity, dependencies),
trackLookup(getIndex, dependencies),
);
queryCache.set(dependencies, value);
}
return value;
}
}
type IndexPath = [key: string, field: string, value: string];
type EntitySchemaPath = [key: string] | [key: string, pk: string];
type QueryPath = IndexPath | EntitySchemaPath;
function createDepLookup(getEntity, getIndex): GetDependency<QueryPath> {
return (args: QueryPath) => {
return args.length === 3 ? getIndex(...args) : getEntity(...args);
};
}
function trackLookup<D extends any[], FD extends D>(
lookup: (...args: FD) => any,
dependencies: Dep<D>[],
) {
return ((...args: Parameters<typeof lookup>) => {
const entity = lookup(...args);
dependencies.push({ path: args, entity });
return entity;
}) as any;
}
export function createGetEntity(
entities:
| EntityTable
| {
getIn(k: string[]): { toJS(): any } | undefined;
},
) {
const entityIsImmutable = isImmutable(entities);
if (entityIsImmutable) {
return (...args) => entities.getIn(args)?.toJS?.();
} else {
return (entityKey: string | symbol, pk?: string): any =>
pk ? entities[entityKey]?.[pk] : entities[entityKey];
}
}
export function createGetIndex(
indexes:
| NormalizedIndex
| {
getIn(k: string[]): any;
},
) {
const entityIsImmutable = isImmutable(indexes);
if (entityIsImmutable) {
return (
key: string,
field: string,
value: string,
): { readonly [indexKey: string]: string | undefined } =>
indexes.getIn([key, field])?.toJS?.();
} else {
return (
key: string,
field: string,
value: string,
): { readonly [indexKey: string]: string | undefined } => {
if (indexes[key]) {
return indexes[key][field];
}
return {};
};
}
}