@data-client/endpoint
Version:
Declarative Network Interface Definitions
375 lines (339 loc) • 11.3 kB
text/typescript
import { consistentSerialize } from './consistentSerialize.js';
import { CheckLoop, GetEntity, PolymorphicInterface } from '../interface.js';
import { Values, Array as ArraySchema } from '../schema.js';
import type { DefaultArgs, EntityInterface } from '../schemaTypes.js';
const pushMerge = (existing: any, incoming: any) => {
return [...existing, ...incoming];
};
const unshiftMerge = (existing: any, incoming: any) => {
return [...incoming, ...existing];
};
const valuesMerge = (existing: any, incoming: any) => {
return { ...existing, ...incoming };
};
const createArray = (value: any) => [...value];
const createValue = (value: any) => ({ ...value });
/**
* Entities but for Arrays instead of classes
* @see https://dataclient.io/rest/api/Collection
*/
export default class CollectionSchema<
S extends PolymorphicInterface = any,
Args extends any[] = DefaultArgs,
Parent = any,
> {
declare protected nestKey: (parent: any, key: string) => Record<string, any>;
declare protected argsKey?: (...args: any) => Record<string, any>;
declare readonly schema: S;
declare readonly key: string;
declare push: S extends ArraySchema<any> ? CollectionSchema<S, Args, Parent>
: undefined;
declare unshift: S extends ArraySchema<any> ?
CollectionSchema<S, Args, Parent>
: undefined;
declare assign: S extends Values<any> ? CollectionSchema<S, Args, Parent>
: undefined;
addWith<P extends any[] = Args>(
merge: (existing: any, incoming: any) => any,
createCollectionFilter?: (
...args: P
) => (collectionKey: Record<string, string>) => boolean,
): CollectionSchema<S, P> {
return CreateAdder(this, merge, createCollectionFilter);
}
// this adds to any list *in store* that has same members as the urlParams
// so fetch(create, { userId: 'bob', completed: true }, data)
// would possibly add to {}, {userId: 'bob'}, {completed: true}, {userId: 'bob', completed: true } - but only those already in the store
// it ignores keys that start with sort as those are presumed to not filter results
protected createCollectionFilter(...args: Args) {
return (collectionKey: Record<string, string>) =>
Object.entries(collectionKey).every(
([key, value]) =>
this.nonFilterArgumentKeys(key) ||
// strings are canonical form. See pk() above for value transformation
`${args[0][key]}` === value ||
`${args[1]?.[key]}` === value,
);
}
protected nonFilterArgumentKeys(key: string) {
return key.startsWith('order');
}
constructor(schema: S, options?: CollectionOptions<Args, Parent>) {
this.schema =
Array.isArray(schema) ? (new ArraySchema(schema[0]) as any) : schema;
if (!options) {
this.argsKey = params => ({ ...params });
} else {
if ('nestKey' in options) {
(this as any).nestKey = options.nestKey;
} else if ('argsKey' in options) {
this.argsKey = options.argsKey;
} else {
this.argsKey = params => ({ ...params });
}
}
this.key = keyFromSchema(this.schema);
if ((options as any)?.nonFilterArgumentKeys) {
const { nonFilterArgumentKeys } = options as {
nonFilterArgumentKeys: ((key: string) => boolean) | string[] | RegExp;
};
if (typeof nonFilterArgumentKeys === 'function') {
this.nonFilterArgumentKeys = nonFilterArgumentKeys;
} else if (nonFilterArgumentKeys instanceof RegExp) {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.test(key);
} else {
this.nonFilterArgumentKeys = key => nonFilterArgumentKeys.includes(key);
}
} else if ((options as any)?.createCollectionFilter)
// TODO(breaking): rename to filterCollections
this.createCollectionFilter = (
options as any as {
createCollectionFilter: (
...args: Args
) => (collectionKey: Record<string, string>) => boolean;
}
).createCollectionFilter.bind(this) as any;
// >>>>>>>>>>>>>>CREATION<<<<<<<<<<<<<<
if (this.schema instanceof ArraySchema) {
this.createIfValid = createArray;
this.push = CreateAdder(this, pushMerge);
this.unshift = CreateAdder(this, unshiftMerge);
} else if (schema instanceof Values) {
this.createIfValid = createValue;
this.assign = CreateAdder(this, valuesMerge);
}
}
get cacheWith(): object {
return this.schema.schema;
}
toString() {
return this.key;
}
toJSON() {
return {
key: this.key,
schema: this.schema.schema.toJSON(),
};
}
pk(value: any, parent: any, key: string, args: readonly any[]) {
const obj =
this.argsKey ? this.argsKey(...args) : this.nestKey(parent, key);
for (const key in obj) {
if (['number', 'boolean'].includes(typeof obj[key]))
obj[key] = `${obj[key]}`;
}
return consistentSerialize(obj);
}
// >>>>>>>>>>>>>>NORMALIZE<<<<<<<<<<<<<<
normalize(
input: any,
parent: Parent,
key: string,
args: any[],
visit: (...args: any) => any,
addEntity: (...args: any) => any,
getEntity: any,
checkLoop: any,
): string {
const normalizedValue = this.schema.normalize(
input,
parent,
key,
args,
visit,
addEntity,
getEntity,
checkLoop,
);
const id = this.pk(normalizedValue, parent, key, args);
addEntity(this, normalizedValue, id);
return id;
}
// always replace
merge(existing: any, incoming: any) {
return incoming;
}
shouldReorder(
existingMeta: { date: number; fetchedAt: number },
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return incomingMeta.fetchedAt < existingMeta.fetchedAt;
}
mergeWithStore(
existingMeta: {
date: number;
fetchedAt: number;
},
incomingMeta: { date: number; fetchedAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ?
this.merge(incoming, existing)
: this.merge(existing, incoming);
}
mergeMetaWithStore(
existingMeta: {
fetchedAt: number;
date: number;
expiresAt: number;
},
incomingMeta: { fetchedAt: number; date: number; expiresAt: number },
existing: any,
incoming: any,
) {
return this.shouldReorder(existingMeta, incomingMeta, existing, incoming) ?
existingMeta
: incomingMeta;
}
// >>>>>>>>>>>>>>DENORMALIZE<<<<<<<<<<<<<<
queryKey(
args: Args,
queryKey: unknown,
getEntity: GetEntity,
getIndex: unknown,
): any {
if (this.argsKey) {
const id = this.pk(undefined, undefined, '', args);
// ensure this actually has entity or we shouldn't try to use it in our query
if (getEntity(this.key, id)) return id;
}
}
declare createIfValid: (value: any) => any | undefined;
denormalize(
input: any,
args: readonly any[],
unvisit: (schema: any, input: any) => any,
): ReturnType<S['denormalize']> {
return this.schema.denormalize(input, args, unvisit) as any;
}
}
export type CollectionOptions<
Args extends any[] = DefaultArgs,
Parent = any,
> = (
| {
/** Defines lookups for Collections nested in other schemas.
*
* @see https://dataclient.io/rest/api/Collection#nestKey
*/
nestKey?: (parent: Parent, key: string) => Record<string, any>;
}
| {
/** Defines lookups top-level Collections using ...args.
*
* @see https://dataclient.io/rest/api/Collection#argsKey
*/
argsKey?: (...args: Args) => Record<string, any>;
}
) &
(
| {
/** Sets a default createCollectionFilter for addWith(), push, unshift, and assign.
*
* @see https://dataclient.io/rest/api/Collection#createcollectionfilter
*/
createCollectionFilter?: (
...args: Args
) => (collectionKey: Record<string, string>) => boolean;
}
| {
/** Test to determine which arg keys should **not** be used for filtering results.
*
* @see https://dataclient.io/rest/api/Collection#nonfilterargumentkeys
*/
nonFilterArgumentKeys?: ((key: string) => boolean) | string[] | RegExp;
}
);
function CreateAdder<C extends CollectionSchema<any, any>, P extends any[]>(
collection: C,
merge: (existing: any, incoming: any) => any[],
createCollectionFilter?: (
...args: P
) => (collectionKey: Record<string, string>) => boolean,
) {
const properties: PropertyDescriptorMap = {
merge: { value: merge },
normalize: { value: normalizeCreate },
queryKey: { value: queryKeyCreate },
};
if (collection.schema instanceof ArraySchema) {
properties.createIfValid = { value: createIfValid };
properties.denormalize = { value: denormalize };
}
if (createCollectionFilter) {
properties.createCollectionFilter = { value: createCollectionFilter };
}
return Object.create(collection, properties);
}
function queryKeyCreate() {}
function normalizeCreate(
this: CollectionSchema<any, any>,
input: any,
parent: any,
key: string,
args: readonly any[],
visit: ((...args: any) => any) & { creating?: boolean },
addEntity: (schema: any, processedEntity: any, id: string) => void,
getEntity: GetEntity,
checkLoop: CheckLoop,
): any {
if (process.env.NODE_ENV !== 'production') {
// means 'this is a creation endpoint' - so real PKs are not required
// this is used by Entity.normalize() to determine whether to allow empty pks
// visit instances are created on each normalize call so this will safely be reset
visit.creating = true;
}
const normalizedValue = this.schema.normalize(
!(this.schema instanceof ArraySchema) || Array.isArray(input) ?
input
: [input],
parent,
key,
args,
visit,
addEntity,
getEntity,
checkLoop,
);
// parent is args when not nested
const filterCollections = (this.createCollectionFilter as any)(...args);
// add to any collections that match this
const entities = getEntity(this.key);
if (entities)
Object.keys(entities).forEach(collectionPk => {
if (!filterCollections(JSON.parse(collectionPk))) return;
addEntity(this, normalizedValue, collectionPk);
});
return normalizedValue as any;
}
function createIfValid(value: object): any | undefined {
return Array.isArray(value) ? [...value] : { ...value };
}
// only for arrays
function denormalize(
this: CollectionSchema<any, any>,
input: any,
args: readonly any[],
unvisit: (schema: any, input: any) => any,
): any {
return Array.isArray(input) ?
(this.schema.denormalize(input, args, unvisit) as any)
: (this.schema.denormalize([input], args, unvisit)[0] as any);
}
/**
* We call schema.denormalize and schema.normalize directly
* instead of visit/unvisit as we are not operating on new data
* so the additional checks in those methods are redundant
*/
function keyFromSchema(schema: PolymorphicInterface) {
if (schema instanceof ArraySchema) {
// this assumes the definition of Array/Values is Entity
return `[${schema.schemaKey()}]`;
} else if (schema instanceof Values) {
return `{${schema.schemaKey()}}`;
}
return `(${schema.schemaKey()})`;
}