/*!
* Convert JS SDK
* Version 1.0.0
* Copyright(c) 2020 Convert Insights, Inc
* License Apache-2.0
*/
import {arrayNotEmpty} from './utils/array-utils';
import {Comparisons as DEFAULT_COMPARISON_PROCESSOR} from './utils/comparisons';
import {RuleManagerInterface} from './interfaces/rule-manager';
import {Config} from './types/Config';
import {Rule, RuleAnd, RuleOrWhen, RuleSet} from './types/Rule';
import {objectDeepValue} from './utils/object-utils';
import {LogManagerInterface} from './interfaces/log-manager';
import {ERROR_MESSAGES, MESSAGES} from './enums/dictionary';
const DEFAULT_KEYS_CASE_SENSITIVE = true;
const DEFAULT_NEGATION = '!';
/**
* Provides rule processing calculations with corresponding comparisons methods
* @category Modules
* @constructor
* @implements {RuleManagerInterface}
*/
export class RuleManager implements RuleManagerInterface {
private _comparisonProcessor: Record<string, any> =
DEFAULT_COMPARISON_PROCESSOR;
private readonly _negation: string = DEFAULT_NEGATION;
private readonly _keys_case_sensitive: boolean = DEFAULT_KEYS_CASE_SENSITIVE;
private _loggerManager: LogManagerInterface | null;
/**
* @param {Config=} config
* @param {Object=} dependencies
* @param {LogManagerInterface=} dependencies.loggerManager
*/
constructor(
config?: Config,
{loggerManager}: {loggerManager?: LogManagerInterface} = {}
) {
this._loggerManager = loggerManager;
this._comparisonProcessor = objectDeepValue(
config,
'rules.comparisonProcessor',
DEFAULT_COMPARISON_PROCESSOR
);
this._negation = String(
objectDeepValue(config, 'rules.negation', DEFAULT_NEGATION)
).valueOf();
this._keys_case_sensitive = objectDeepValue(
config,
'rules.keys_case_sensitive',
DEFAULT_KEYS_CASE_SENSITIVE,
true
);
this._loggerManager?.trace?.(MESSAGES.RULE_CONSTRUCTOR, this);
}
/**
* Setter for comparison processor
* @param {object} comparisonProcessor
*/
set comparisonProcessor(comparisonProcessor: Record<string, any>) {
this._comparisonProcessor = comparisonProcessor;
}
/**
* Getter for comparison processor
*/
get comparisonProcessor(): Record<string, any> {
return this._comparisonProcessor;
}
/**
* Retrieve comparison methods from comparison processor
* @return {Array<string>} List of methods of comparison processor
*/
getComparisonProcessorMethods(): Array<string> {
return Object.getOwnPropertyNames(this._comparisonProcessor).filter(
(name) => typeof this._comparisonProcessor[name] === 'function'
);
}
/**
* Check input data matching to rule set
* @param {Record<string, string | number> | string | number} data Single value or key-value data set to compare
* @param {RuleSet} ruleSet
* @return {boolean}
*/
isRuleMatched(
data: Record<string, string | number> | string | number,
ruleSet: RuleSet
): boolean {
this._loggerManager?.debug?.('RuleManager.isRuleMatched()', {
data: data,
ruleSet: ruleSet
});
// Top OR level
if (
Object.prototype.hasOwnProperty.call(ruleSet, 'OR') &&
arrayNotEmpty(ruleSet?.OR)
) {
for (let i = 0, l = ruleSet.OR.length; i < l; i++) {
if (this._processAND(data, ruleSet.OR[i])) {
return true;
}
}
} else {
this._loggerManager?.warn?.(ERROR_MESSAGES.RULE_NOT_VALID);
}
return false;
}
/**
* Check is rule object valid
* @param {object} rule
* @return {boolean}
*/
isValidRule(rule: Rule): boolean {
this._loggerManager?.debug?.('RuleManager.isValidRule()', {
rule: rule
});
return (
Object.prototype.hasOwnProperty.call(rule, 'key') && // comment when supporting direct value on _processRule()
Object.prototype.hasOwnProperty.call(rule, 'matching') &&
typeof rule.matching === 'object' &&
Object.prototype.hasOwnProperty.call(rule.matching, 'match_type') &&
typeof rule.matching.match_type === 'string' &&
Object.prototype.hasOwnProperty.call(rule.matching, 'negated') &&
typeof rule.matching.negated === 'boolean' &&
Object.prototype.hasOwnProperty.call(rule, 'value')
);
}
/**
* Process AND block of rule set. Return first false if found
* @param {object | string | number | boolean} data Single value or key-value data set to compare
* @param {RuleAnd} rulesSubset
* @return {boolean}
* @private
*/
private _processAND(
data: Record<string, string | number> | string | number,
rulesSubset: RuleAnd
): boolean {
// Second AND level
if (
Object.prototype.hasOwnProperty.call(rulesSubset, 'AND') &&
arrayNotEmpty(rulesSubset?.AND)
) {
for (let i = 0, l = rulesSubset.AND.length; i < l; i++) {
if (this._processORWHEN(data, rulesSubset.AND[i]) === false) {
return false;
}
}
return true;
} else {
this._loggerManager?.warn?.(ERROR_MESSAGES.RULE_NOT_VALID);
}
return false;
}
/**
* Process OR block of rule set. Return first true if found
* @param {object | string | number | boolean} data Single value or key-value data set to compare
* @param {RuleOrWhen} rulesSubset
* @return {boolean}
* @private
*/
private _processORWHEN(
data: Record<string, string | number> | string | number,
rulesSubset: RuleOrWhen
): boolean {
// Third OR level. Called OR_WHEN.
if (
Object.prototype.hasOwnProperty.call(rulesSubset, 'OR_WHEN') &&
arrayNotEmpty(rulesSubset?.OR_WHEN)
) {
for (let i = 0, l = rulesSubset.OR_WHEN.length; i < l; i++) {
if (this._processRule(data, rulesSubset.OR_WHEN[i])) {
return true;
}
}
} else {
this._loggerManager?.warn?.(ERROR_MESSAGES.RULE_NOT_VALID);
}
return false;
}
/**
* Process single rule
* @param {object | string | number | boolean} data Single value or key-value data set to compare
* @param {Rule} rule A single rule to compare
* @return {boolean} Comparison result
* @private
*/
private _processRule(
data: Record<string, string | number> | string | number,
rule: Rule
): boolean {
if (this.isValidRule(rule)) {
try {
const negation = rule.matching.negated || false;
const matching = rule.matching.match_type;
if (this.getComparisonProcessorMethods().indexOf(matching) !== -1) {
const dataType = typeof data;
switch (dataType) {
// Validate direct value. Rule object `key` field is ignored or not present
// case 'boolean':
// case 'number':
// case 'bigint':
// case 'string':
// return this._comparisonProcessor[matching](
// data,
// rule.value,
// negation
// );
// break;
// Validate data key-value set. Rule object has to have `key` field
case 'object':
for (const key of Object.keys(data)) {
const k = this._keys_case_sensitive ? key : key.toLowerCase();
const rule_k = this._keys_case_sensitive
? rule.key
: rule.key.toLowerCase();
if (k === rule_k) {
return this._comparisonProcessor[matching](
data[key],
rule.value,
negation
);
}
}
break;
default:
this._loggerManager?.warn?.('RuleManager._processRule()', {
warn: ERROR_MESSAGES.RULE_DATA_NOT_VALID
});
}
}
} catch (error) {
this._loggerManager?.error?.('RuleManager._processRule()', {
error: error.message
});
}
} else {
this._loggerManager?.warn?.(ERROR_MESSAGES.RULE_NOT_VALID);
}
return false;
}
}
Source