@rimbu/deep
Version:
Tools to use handle plain JS objects as immutable objects
701 lines (607 loc) • 17.6 kB
text/typescript
import {
type IsAnyFunc,
type IsArray,
isPlainObj,
type IsPlainObj,
type NotIterable,
} from '@rimbu/base';
import type { Protected } from './internal.mjs';
import type { Tuple } from './tuple.mjs';
/**
* The type to determine the allowed input values for the `match` function.
* @typeparam T - the type of value to match
* @typeparam C - utility type
*/
export type Match<T, C extends Partial<T> = Partial<T>> = Match.Entry<
T,
C,
T,
T
>;
export namespace Match {
/**
* Determines the various allowed match types for given type `T`.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam P - the parent type
* @typeparam R - the root object type
*/
export type Entry<T, C, P, R> = IsAnyFunc<T> extends true
? // function can only be directly matched
T
: IsPlainObj<T> extends true
? // determine allowed match values for object
Match.WithResult<T, P, R, Match.Obj<T, C, P, R>>
: IsArray<T> extends true
? // determine allowed match values for array or tuple
| Match.Arr<T, C, P, R>
| Match.Entry<T[number & keyof T], C[number & keyof C], P, R>[]
| Match.Func<
T,
P,
R,
| Match.Arr<T, C, P, R>
| Match.Entry<T[number & keyof T], C[number & keyof C], P, R>[]
>
: // only accept values with same interface
Match.WithResult<T, P, R, { [K in keyof C]: C[K & keyof T] }>;
/**
* The type that determines allowed matchers for objects.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam P - the parent type
* @typeparam R - the root object type
*/
export type Obj<T, C, P, R> =
| Match.ObjProps<T, C, R>
| Match.CompoundForObj<T, C, P, R>;
/**
* The type to determine allowed matchers for object properties.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam R - the root object type
*/
export type ObjProps<T, C, R> = {
[K in keyof C]?: K extends keyof T ? Match.Entry<T[K], C[K], T, R> : never;
};
/**
* The type that determines allowed matchers for arrays/tuples.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam P - the parent type
* @typeparam R - the root object type
*/
export type Arr<T, C, P, R> =
| C
| Match.CompoundForArr<T, C, P, R>
| Match.TraversalForArr<T, C, R>
| (Match.TupIndices<T, C, R> & {
[K in Match.CompoundType | Match.ArrayTraversalType]?: never;
});
/**
* A type that either directly results in result type `S` or is a function taking the value, parent, and root values, and
* returns a value of type `S`.
* @typeparam T - the input value type
* @typeparam P - the parent type
* @typeparam R - the root object type
* @typeparam S - the result type
*/
export type WithResult<T, P, R, S> = S | Match.Func<T, P, R, S>;
/**
* Type used to determine the allowed function types. Always includes booleans.
* @typeparam T - the input value type
* @typeparam P - the parent type
* @typeparam R - the root object type
* @typeparam S - the allowed return value type
*/
export type Func<T, P, R, S> = (
current: Protected<T>,
parent: Protected<P>,
root: Protected<R>
) => boolean | S;
/**
* Type used to indicate an object containing matches for tuple indices.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam R - the root object type
*/
export type TupIndices<T, C, R> = {
[K in Tuple.KeysOf<C>]?: Match.Entry<T[K & keyof T], C[K], T, R>;
} & NotIterable;
/**
* Compound keys used to indicate the type of compound.
*/
export type CompoundType = 'every' | 'some' | 'none' | 'single';
/**
* Keys used to indicate an array match traversal.
*/
export type ArrayTraversalType = `${CompoundType}Item`;
/**
* Compount matcher for objects, can only be an array staring with a compound type keyword.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam P - the parent type
* @typeparam R - the root object type
*/
export type CompoundForObj<T, C, P, R> = [
Match.CompoundType,
...Match.Entry<T, C, P, R>[]
];
/**
* Defines an object containing exactly one `CompoundType` key, having an array of matchers.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam P - the parent type
* @typeparam R - the root object type
*/
export type CompoundForArr<T, C, P, R> = {
[K in Match.CompoundType]: {
[K2 in Match.CompoundType]?: K2 extends K
? Match.Entry<T, C, P, R>[]
: never;
};
}[Match.CompoundType];
/**
* Defines an object containing exactly one `TraversalType` key, having a matcher for the array element type.
* @typeparam T - the input value type
* @typeparam C - utility type
* @typeparam R - the root object type
*/
export type TraversalForArr<T, C, R> = {
[K in Match.ArrayTraversalType]: {
[K2 in Match.ArrayTraversalType]?: K2 extends K
? Match.Entry<T[number & keyof T], C[number & keyof C], T, R>
: never;
};
}[Match.ArrayTraversalType];
/**
* Utility type for collecting match failure reasons
*/
export type FailureLog = string[];
}
/**
* Returns true if the given `value` object matches the given `matcher`, false otherwise.
* @typeparam T - the input value type
* @typeparam C - utility type
* @param source - the value to match (should be a plain object)
* @param matcher - a matcher object or a function taking the matcher API and returning a match object
* @param failureLog - (optional) a string array that can be passed to collect reasons why the match failed
* @example
* ```ts
* const input = { a: 1, b: { c: true, d: 'a' } }
* match(input, { a: 1 }) // => true
* match(input, { a: 2 }) // => false
* match(input, { a: (v) => v > 10 }) // => false
* match(input, { b: { c: true }}) // => true
* match(input, (['every', { a: (v) => v > 0 }, { b: { c: true } }]) // => true
* match(input, { b: { c: (v, parent, root) => v && parent.d.length > 0 && root.a > 0 } })
* // => true
* ```
*/
export function match<T, C extends Partial<T> = Partial<T>>(
source: T,
matcher: Match<T, C>,
failureLog?: Match.FailureLog
): boolean {
return matchEntry(source, source, source, matcher as any, failureLog);
}
/**
* Match a generic match entry against the given source.
*/
function matchEntry<T, C, P, R>(
source: T,
parent: P,
root: R,
matcher: Match.Entry<T, C, P, R>,
failureLog?: Match.FailureLog
): boolean {
if (Object.is(source, matcher)) {
// value and target are exactly the same, always will be true
return true;
}
if (matcher === null || matcher === undefined) {
// these matchers can only be direct matches, and previously it was determined that
// they are not equal
failureLog?.push(
`value ${JSON.stringify(source)} did not match matcher ${matcher}`
);
return false;
}
if (typeof source === 'function') {
// function source values can only be directly matched
const result = Object.is(source, matcher);
if (!result) {
failureLog?.push(
`both value and matcher are functions, but they do not have the same reference`
);
}
return result;
}
if (typeof matcher === 'function') {
// resolve match function first
const matcherResult = matcher(source, parent, root);
if (typeof matcherResult === 'boolean') {
// function resulted in a direct match result
if (!matcherResult) {
failureLog?.push(
`function matcher returned false for value ${JSON.stringify(source)}`
);
}
return matcherResult;
}
// function resulted in a value that needs to be further matched
return matchEntry(source, parent, root, matcherResult, failureLog);
}
if (isPlainObj(source)) {
// source ia a plain object, can be partially matched
return matchPlainObj(source, parent, root, matcher as any, failureLog);
}
if (Array.isArray(source)) {
// source is an array
return matchArr(source, parent, root, matcher as any, failureLog);
}
// already determined above that the source and matcher are not equal
failureLog?.push(
`value ${JSON.stringify(
source
)} does not match given matcher ${JSON.stringify(matcher)}`
);
return false;
}
/**
* Match an array matcher against the given source.
*/
function matchArr<T extends any[], C, P, R>(
source: T,
parent: P,
root: R,
matcher: Match.Arr<T, C, P, R>,
failureLog?: Match.FailureLog
): boolean {
if (Array.isArray(matcher)) {
// directly compare array contents
const length = source.length;
if (length !== matcher.length) {
// if lengths not equal, arrays are not equal
failureLog?.push(
`array lengths are not equal: value length ${source.length} !== matcher length ${matcher.length}`
);
return false;
}
// loop over arrays, matching every value
let index = -1;
while (++index < length) {
if (
!matchEntry(source[index], source, root, matcher[index], failureLog)
) {
// item did not match, return false
failureLog?.push(
`index ${index} does not match with value ${JSON.stringify(
source[index]
)} and matcher ${matcher[index]}`
);
return false;
}
}
// all items are equal
return true;
}
// matcher is plain object
if (typeof matcher === 'object' && null !== matcher) {
if (`every` in matcher) {
return matchCompound(
source,
parent,
root,
['every', ...(matcher.every as any)],
failureLog
);
}
if (`some` in matcher) {
return matchCompound(
source,
parent,
root,
['some', ...(matcher.some as any)],
failureLog
);
}
if (`none` in matcher) {
return matchCompound(
source,
parent,
root,
['none', ...(matcher.none as any)],
failureLog
);
}
if (`single` in matcher) {
return matchCompound(
source,
parent,
root,
['single', ...(matcher.single as any)],
failureLog
);
}
if (`someItem` in matcher) {
return matchTraversal(
source,
root,
'someItem',
matcher.someItem as any,
failureLog
);
}
if (`everyItem` in matcher) {
return matchTraversal(
source,
root,
'everyItem',
matcher.everyItem as any,
failureLog
);
}
if (`noneItem` in matcher) {
return matchTraversal(
source,
root,
'noneItem',
matcher.noneItem as any,
failureLog
);
}
if (`singleItem` in matcher) {
return matchTraversal(
source,
root,
'singleItem',
matcher.singleItem as any,
failureLog
);
}
}
// matcher is plain object with index keys
for (const index in matcher as any) {
const matcherAtIndex = (matcher as any)[index];
if (!(index in source)) {
// source does not have item at given index
failureLog?.push(
`index ${index} does not exist in source ${JSON.stringify(
source
)} but should match matcher ${JSON.stringify(matcherAtIndex)}`
);
return false;
}
// match the source item at the given index
const result = matchEntry(
(source as any)[index],
source,
root,
matcherAtIndex,
failureLog
);
if (!result) {
// item did not match
failureLog?.push(
`index ${index} does not match with value ${JSON.stringify(
(source as any)[index]
)} and matcher ${JSON.stringify(matcherAtIndex)}`
);
return false;
}
}
// all items match
return true;
}
/**
* Match an object matcher against the given source.
*/
function matchPlainObj<T extends object, C, P, R>(
source: T,
parent: P,
root: R,
matcher: Match.Obj<T, C, P, R>,
failureLog?: Match.FailureLog
): boolean {
if (Array.isArray(matcher)) {
// the matcher is of compound type
return matchCompound(source, parent, root, matcher as any, failureLog);
}
// partial object props matcher
for (const key in matcher) {
if (!(key in source)) {
// the source does not have the given key
failureLog?.push(
`key ${key} is specified in matcher but not present in value ${JSON.stringify(
source
)}`
);
return false;
}
// match the source value at the given key with the matcher at given key
const result = matchEntry(
(source as any)[key],
source,
root,
matcher[key],
failureLog
);
if (!result) {
failureLog?.push(
`key ${key} does not match in value ${JSON.stringify(
(source as any)[key]
)} with matcher ${JSON.stringify(matcher[key])}`
);
return false;
}
}
// all properties match
return true;
}
/**
* Match a compound matcher against the given source.
*/
function matchCompound<T, C, P, R>(
source: T,
parent: P,
root: R,
compound: [Match.CompoundType, ...Match.Entry<T, C, P, R>[]],
failureLog?: Match.FailureLog
): boolean {
// first item indicates compound match type
const matchType = compound[0];
const length = compound.length;
// start at index 1
let index = 0;
type Entry = Match.Entry<T, C, P, R>;
switch (matchType) {
case 'every': {
while (++index < length) {
// if any item does not match, return false
const result = matchEntry(
source,
parent,
root,
compound[index] as Entry,
failureLog
);
if (!result) {
failureLog?.push(
`in compound "every": match at index ${index} failed`
);
return false;
}
}
return true;
}
case 'none': {
// if any item matches, return false
while (++index < length) {
const result = matchEntry(
source,
parent,
root,
compound[index] as Entry,
failureLog
);
if (result) {
failureLog?.push(
`in compound "none": match at index ${index} succeeded`
);
return false;
}
}
return true;
}
case 'single': {
// if not exactly one item matches, return false
let onePassed = false;
while (++index < length) {
const result = matchEntry(
source,
parent,
root,
compound[index] as Entry,
failureLog
);
if (result) {
if (onePassed) {
failureLog?.push(
`in compound "single": multiple matches succeeded`
);
return false;
}
onePassed = true;
}
}
if (!onePassed) {
failureLog?.push(`in compound "single": no matches succeeded`);
}
return onePassed;
}
case 'some': {
// if any item matches, return true
while (++index < length) {
const result = matchEntry(
source,
parent,
root,
compound[index] as Entry,
failureLog
);
if (result) {
return true;
}
}
failureLog?.push(`in compound "some": no matches succeeded`);
return false;
}
}
}
function matchTraversal<T extends any[], C extends any[], R>(
source: T,
root: R,
matchType: Match.ArrayTraversalType,
matcher: Match.Entry<T[keyof T], C[keyof C], T, R>,
failureLog?: Match.FailureLog
): boolean {
let index = -1;
const length = source.length;
switch (matchType) {
case 'someItem': {
while (++index < length) {
if (matchEntry(source[index], source, root, matcher, failureLog)) {
return true;
}
}
failureLog?.push(
`in array traversal "someItem": no items matched given matcher`
);
return false;
}
case 'everyItem': {
while (++index < length) {
if (!matchEntry(source[index], source, root, matcher, failureLog)) {
failureLog?.push(
`in array traversal "everyItem": at least one item did not match given matcher`
);
return false;
}
}
return true;
}
case 'noneItem': {
while (++index < length) {
if (matchEntry(source[index], source, root, matcher, failureLog)) {
failureLog?.push(
`in array traversal "noneItem": at least one item matched given matcher`
);
return false;
}
}
return true;
}
case 'singleItem': {
let singleMatched = false;
while (++index < length) {
if (matchEntry(source[index], source, root, matcher, failureLog)) {
if (singleMatched) {
failureLog?.push(
`in array traversal "singleItem": more than one item matched given matcher`
);
return false;
}
singleMatched = true;
}
}
if (!singleMatched) {
failureLog?.push(
`in array traversal "singleItem": no item matched given matcher`
);
return false;
}
return true;
}
}
}