@rimbu/deep
Version:
Tools to use handle plain JS objects as immutable objects
279 lines • 11.4 kB
JavaScript
import { isPlainObj, } from '@rimbu/base';
/**
* 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(source, matcher, failureLog) {
return matchEntry(source, source, source, matcher, failureLog);
}
/**
* Match a generic match entry against the given source.
*/
function matchEntry(source, parent, root, matcher, failureLog) {
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, failureLog);
}
if (Array.isArray(source)) {
// source is an array
return matchArr(source, parent, root, matcher, 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(source, parent, root, matcher, failureLog) {
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], failureLog);
}
if (`some` in matcher) {
return matchCompound(source, parent, root, ['some', ...matcher.some], failureLog);
}
if (`none` in matcher) {
return matchCompound(source, parent, root, ['none', ...matcher.none], failureLog);
}
if (`single` in matcher) {
return matchCompound(source, parent, root, ['single', ...matcher.single], failureLog);
}
if (`someItem` in matcher) {
return matchTraversal(source, root, 'someItem', matcher.someItem, failureLog);
}
if (`everyItem` in matcher) {
return matchTraversal(source, root, 'everyItem', matcher.everyItem, failureLog);
}
if (`noneItem` in matcher) {
return matchTraversal(source, root, 'noneItem', matcher.noneItem, failureLog);
}
if (`singleItem` in matcher) {
return matchTraversal(source, root, 'singleItem', matcher.singleItem, failureLog);
}
}
// matcher is plain object with index keys
for (const index in matcher) {
const matcherAtIndex = matcher[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[index], source, root, matcherAtIndex, failureLog);
if (!result) {
// item did not match
failureLog?.push(`index ${index} does not match with value ${JSON.stringify(source[index])} and matcher ${JSON.stringify(matcherAtIndex)}`);
return false;
}
}
// all items match
return true;
}
/**
* Match an object matcher against the given source.
*/
function matchPlainObj(source, parent, root, matcher, failureLog) {
if (Array.isArray(matcher)) {
// the matcher is of compound type
return matchCompound(source, parent, root, matcher, 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[key], source, root, matcher[key], failureLog);
if (!result) {
failureLog?.push(`key ${key} does not match in value ${JSON.stringify(source[key])} with matcher ${JSON.stringify(matcher[key])}`);
return false;
}
}
// all properties match
return true;
}
/**
* Match a compound matcher against the given source.
*/
function matchCompound(source, parent, root, compound, failureLog) {
// first item indicates compound match type
const matchType = compound[0];
const length = compound.length;
// start at index 1
let index = 0;
switch (matchType) {
case 'every': {
while (++index < length) {
// if any item does not match, return false
const result = matchEntry(source, parent, root, compound[index], 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], 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], 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], failureLog);
if (result) {
return true;
}
}
failureLog?.push(`in compound "some": no matches succeeded`);
return false;
}
}
}
function matchTraversal(source, root, matchType, matcher, failureLog) {
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;
}
}
}
//# sourceMappingURL=match.mjs.map