diff --git a/.eslintrc.js b/.eslintrc.js index 6f3602e..f7e11b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,7 +32,7 @@ module.exports = { }, }, "newlines-between": "always", - "max-line-length": 80, + "max-line-length": 140, }, ], "perfectionist/sort-named-imports": [ diff --git a/.github/workflows/jest.yaml b/.github/workflows/jest.yaml index ce7e4e6..851bc43 100644 --- a/.github/workflows/jest.yaml +++ b/.github/workflows/jest.yaml @@ -8,10 +8,19 @@ jobs: coverage: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: ArtiomTr/jest-coverage-report-action@v2 + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v3 with: - test-script: yarn jest --testPathPattern=test --color --forceExit --coverage - - run: yarn - - run: yarn build - - run: yarn test + node-version: '20' + + - name: Install dependencies + run: yarn install + + - name: Install dependencies + run: yarn build + + - name: Run Jest tests + run: yarn test diff --git a/.github/workflows/npm-publish-github-packages.yaml b/.github/workflows/npm-publish-github-packages.yaml index 64cc55e..8eae06e 100644 --- a/.github/workflows/npm-publish-github-packages.yaml +++ b/.github/workflows/npm-publish-github-packages.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - run: yarn - run: yarn build - run: yarn test diff --git a/README.md b/README.md index 5f1294e..94a36e0 100644 --- a/README.md +++ b/README.md @@ -428,17 +428,15 @@ let result = await RulePilot.evaluate(rule, criteria); // result = [true, false] ### Sub Rules -Sub-rules can be used to create early exit points rules by assigning a `rule` property to a condition. This rule property -will contain a new rule in itself which will be evaluated if the constraints of the host condition are met. +Sub-rules can be used to create early exit points in a rule by assigning a `result` property to a condition. -Sub-rules do not need to evaluate to true for their parent condition to pass evaluation. They contain their own result -which will be returned if the following conditions are met: +This value of this result will be returned when the rule is evaluated if all the constraints from the root of the rule +to this result are met. -- The parent condition (all criteria and/or nested rules) are met -- The sub-rule is met +> Note: Sub-rules do not need to evaluate to true for their parent condition to pass evaluation. -They provide a convenient way to create complex rules with early exit points and avoid repeating the same constraints -in multiple places. +Sub-rules provide a convenient way to create complex rules with early exit points and avoid repeating the same +constraints in multiple places. An example of a sub-rule can be seen below: @@ -449,18 +447,8 @@ const rule: Rule = { conditions: { any: [ { field: "profile.age", operator: ">=", value: 18 }, - { - rule: { - conditions: { all: [{ field: "foo", operator: "==", value: 'A' }] }, - result: 10 - } - }, - { - rule: { - conditions: { all: [{ field: "foo", operator: "==", value: 'B' }] }, - result: 20 - }, - } + { all: [{ field: "foo", operator: "==", value: 'A' }], result: 10 }, + { all: [{ field: "foo", operator: "==", value: 'B' }], result: 20 } ], result: 5 } @@ -597,11 +585,13 @@ process.env.DEBUG = "true"; Rule introspection is built into `RulePilot` and can be used to inspect a rule and get information about it. -when `RulePilot` introspects a rule it attempts to determine, for each distinct result set in the rule, the -distribution of inputs which will satisfy the rule in a manner which evaluates to said result. +When `RulePilot` introspects a rule it attempts to determine, for each distinct result in the rule, the +distribution of inputs which will satisfy the rule resolving to said result provided an input constraint. + +For example, using introspection we can ask Rule Pilot the following question: -What this means is that given any rule, the introspection feature will return all the possible input combinations -which the rule can be evaluated against which will result in all the possible outputs the rule can have. +> Given rule A, If I evaluate the rule with a value X = 100, what are all the possible values of Y for which the rule will +> evaluate to a result, and what results would the rule evaluate to? This is a useful feature when you want to know what inputs will result in a specific output, or what inputs will result in a specific output distribution. @@ -641,39 +631,44 @@ const rule: Rule = { }, ], }; +``` -// Intropect the rule -const introspection = RulePilot.introspect(rule); +We can introspect the rule to determine what countries are available to get a discount if the user has a coupon, and what +the discount amount would be in each case. + +```typescript +const subjects = ["country"]; +const constraint = { field: "hasCoupon", value: true }; +const introspection = RulePilot.introspect(rule, constraint, subjects); ``` -The following will be returned in the `introspection` variable: +The following will be returned by the `introspection`: ```json { - "results": [ - { - "result": 5, - "options": [ - { "country": "SE" }, - { "country": ["GB", "FI"], "hasCoupon": true, "totalCheckoutPrice": { "operator": ">=", "value": 120 } } - ] - }, - { - "result": 10, - "options": [ - { "age": { "operator": ">=", "value": 18 }, "hasStudentCard": true } - ] - } - ] + "5": { + "country": [{ "value": "SE", "operator": "==" }, { "value": ["GB", "FI"], "operator": "in" }] + } } ``` -Each possible result that the rule can evaluate to is returned in the `results` array, along with the possible inputs -which will result in that result in the `options` array. +```typescript +const subjects = ["country"]; +const constraint = { field: "totalCheckoutPrice", value: 100 }; +const introspection = RulePilot.introspect(rule, constraint, subjects); +``` + +The following will be returned by the `introspection`: + +```json +{ + "5": { + "country": [{ "value": "SE", "operator": "==" }] + } +} +``` -Each object in the `options` array is a set of criteria which must be met in order for the rule to evaluate to the -result, we can consider the list of objects in the `options` as an `OR` and the criteria inside each object as -an `AND`. +Each object in the `response` criteria which are possible inputs for the rule to evaluate to the result provided. Although calculating such results might seem trivial, it can be in fact be quite a complex thing to do especially when dealing with complex rules with multiple nested conditions comprised of many different operators. @@ -738,18 +733,6 @@ import { RulePilot, Rule } from "rulepilot"; const builder = RulePilot.builder(); -const subRule = builder.subRule(); -subRule.add( - subRule.condition( - "all", - [ - subRule.constraint("color", "==", "green"), - subRule.constraint("discount", ">", 20), - ], - { price: 10 } - ) -); - const rule: Rule = builder .add( builder.condition( @@ -758,11 +741,15 @@ const rule: Rule = builder builder.condition("any", [ builder.constraint("size", "==", "medium"), builder.constraint("weight", ">=", 2), + builder.condition( + "all", + [builder.constraint("color", "==", "green"), builder.constraint("discount", ">", 20)], + { price: 10 } + ) ]), builder.constraint("category", "not in", ["free", "out of stock"]), ], { price: 20 }, - subRule ) ) .add( diff --git a/package.json b/package.json index 2cf4c49..051b7e1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "rulepilot", - "version": "1.3.1", + "version": "1.4.0", "description": "Rule parsing engine for JSON rules", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { "lint": "eslint \"{src,test}/**/*.ts\" --fix", "badges": "jest-badges-readme", - "test": "jest --testPathPattern=test --detectOpenHandles --color --forceExit", + "test": "jest --testPathPattern=test --color --forceExit", "build": "rm -rf dist && tsc", "prettier": "prettier --write .", "publish": "npm run build && npm publish --access public" diff --git a/prettier.json b/prettier.json index 72f7c03..ed273d2 100644 --- a/prettier.json +++ b/prettier.json @@ -1,5 +1,5 @@ { - "printWidth": 80, + "printWidth": 140, "singleQuote": true, "trailingComma": "all" } diff --git a/src/builder/builder.ts b/src/builder/builder.ts index e4e83ab..2cfc199 100644 --- a/src/builder/builder.ts +++ b/src/builder/builder.ts @@ -1,6 +1,5 @@ import { RuleError } from "../errors"; import { Validator } from "../services"; -import { SubRuleBuilder } from "./sub-rule-builder"; import { Rule, Operator, Condition, Constraint, ConditionType } from "../types"; export class Builder { @@ -28,17 +27,14 @@ export class Builder { * @param type The type of condition * @param nodes Any child nodes of the condition * @param result The result of the condition node (for granular rules) - * @param subRule A sub-rule to apply to the condition */ condition( type: ConditionType, nodes: Condition[ConditionType], - result?: Condition["result"], - subRule?: SubRuleBuilder + result?: Condition["result"] ): Condition { return { [type]: nodes, - ...(subRule ? { rule: subRule.build() } : {}), ...(result ? { result } : {}), }; } @@ -83,11 +79,4 @@ export class Builder { throw new RuleError(validationResult); } - - /** - * Creates a new sub-rule builder - */ - subRule(): SubRuleBuilder { - return new SubRuleBuilder(); - } } diff --git a/src/builder/index.ts b/src/builder/index.ts index 26c03a4..0873f75 100644 --- a/src/builder/index.ts +++ b/src/builder/index.ts @@ -1,2 +1 @@ export * from "./builder"; -export * from "./sub-rule-builder"; diff --git a/src/builder/sub-rule-builder.ts b/src/builder/sub-rule-builder.ts deleted file mode 100644 index 0576827..0000000 --- a/src/builder/sub-rule-builder.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - SubRule, - Operator, - Condition, - Constraint, - ConditionType, -} from "../types"; - -export class SubRuleBuilder { - /** Stores the rule being constructed */ - #rule: SubRule = { conditions: [], result: undefined }; - - /** - * Builds the subrule and returns it - */ - build(): SubRule { - return this.#rule; - } - - /** - * Adds a node (in the root) to the rule being constructed - * @param node The node to add to the rule - */ - add(node: Condition): SubRuleBuilder { - (this.#rule.conditions as Condition[]).push(node); - return this; - } - - /** - * Creates a new condition node - * @param type The type of condition - * @param nodes Any child nodes of the condition - * @param result The result of the condition node (for granular rules) - * @param subRule A sub-rule to apply to the condition - */ - condition( - type: ConditionType, - nodes: Condition[ConditionType], - result?: Condition["result"], - subRule?: SubRule - ): Condition { - return { - [type]: nodes, - ...(subRule ? { rule: subRule } : {}), - ...(result ? { result } : {}), - }; - } - - /** - * Creates a new constraint node - * @param field The field to apply the constraint to - * @param operator The operator to apply to the field - * @param value The value to compare the field to - */ - constraint( - field: string, - operator: Operator, - value: Constraint["value"] - ): Constraint { - return { - field, - operator, - value, - }; - } - - /** - * Sets the result of the subrule being constructed - * @param value The default value of the rule - */ - result(value: SubRule["result"]): SubRuleBuilder { - this.#rule.result = value; - return this; - } -} diff --git a/src/services/evaluator.ts b/src/services/evaluator.ts index df85d5e..64d94e3 100644 --- a/src/services/evaluator.ts +++ b/src/services/evaluator.ts @@ -1,11 +1,17 @@ +import { + Rule, + Condition, + Constraint, + WithRequired, + ConditionType, +} from "../types"; import { ObjectDiscovery } from "./object-discovery"; -import { Rule, SubRule, Condition, Constraint, ConditionType } from "../types"; export class Evaluator { #objectDiscovery: ObjectDiscovery = new ObjectDiscovery(); - /** Stores any results from sub-rules */ - #subRuleResults: any[]; + /** Stores any results from nested conditions */ + #nestedResults: any[]; /** * Evaluates a rule against a set of criteria and returns the result. @@ -21,20 +27,20 @@ export class Evaluator { const result: T | boolean[] = []; for (const c of criteria) { // Clear any previous sub-results. - this.#subRuleResults = []; + this.#nestedResults = []; result.push(this.#evaluateRule(rule.conditions, c, rule?.default)); } - return this.#subRuleResults.length - ? this.#subRuleResults[0] + return this.#nestedResults.length + ? this.#nestedResults[0] : (result as T | boolean); } // Clear any previous sub-results. - this.#subRuleResults = []; + this.#nestedResults = []; const e = this.#evaluateRule(rule.conditions, criteria, rule?.default); - return this.#subRuleResults.length ? this.#subRuleResults[0] : e; + return this.#nestedResults.length ? this.#nestedResults[0] : e; } /** @@ -76,8 +82,8 @@ export class Evaluator { const type = this.#objectDiscovery.conditionType(condition); if (!type) return false; - // If the condition has sub-rules we should process them. - this.#processSubRule(condition, criteria, type); + // If the condition has nested results + this.#processNestedResults(condition, criteria, type); // Set the starting result let result: boolean | undefined = undefined; @@ -85,7 +91,7 @@ export class Evaluator { // Check each node in the condition. for (const node of condition[type]) { // Ignore sub-rules when evaluating the condition. - if (this.#objectDiscovery.isSubRule(node)) continue; + if (this.#objectDiscovery.isConditionWithResult(node)) continue; let fn: () => boolean; if (this.#objectDiscovery.isCondition(node)) @@ -108,48 +114,47 @@ export class Evaluator { * @param criteria The criteria to evaluate the condition against. * @param type The parent condition type. */ - #processSubRule(condition: Condition, criteria: object, type: ConditionType) { - // Find the sub-rule within the condition - const subRule: SubRule = ( - condition[type].find((node) => this.#objectDiscovery.isSubRule(node)) as { - rule: SubRule; - } - )?.rule; - if (!subRule) return; - - // Check if all the sibling conditions and constraints pass - let siblingsPass: boolean | undefined = undefined; - for (const node of condition[type]) { - if (this.#objectDiscovery.isSubRule(node)) continue; - - if (this.#objectDiscovery.isCondition(node)) { - siblingsPass = this.#accumulate( - type, - () => this.#evaluateCondition(node, criteria), - siblingsPass - ); - } - - if (this.#objectDiscovery.isConstraint(node)) { - siblingsPass = this.#accumulate( - type, - () => this.#checkConstraint(node, criteria), - siblingsPass - ); + #processNestedResults( + condition: Condition, + criteria: object, + type: ConditionType + ) { + // Find all the nested conditions which have results + const candidates = condition[type].filter((node) => + this.#objectDiscovery.isConditionWithResult(node) + ) as WithRequired[]; + + // For each candidate, check if all the sibling + // conditions and constraints pass + candidates.forEach((candidate) => { + let siblingsPass: boolean | undefined = undefined; + for (const node of condition[type]) { + if (this.#objectDiscovery.isConditionWithResult(node)) continue; + + if (this.#objectDiscovery.isCondition(node)) { + siblingsPass = this.#accumulate( + type, + () => this.#evaluateCondition(node, criteria), + siblingsPass + ); + } + + if (this.#objectDiscovery.isConstraint(node)) { + siblingsPass = this.#accumulate( + type, + () => this.#checkConstraint(node, criteria), + siblingsPass + ); + } } - } - if (!siblingsPass) return; + if (!siblingsPass) return; - // Evaluate the sub-rule - const passed = this.#evaluateRule( - subRule.conditions, - criteria, - false, - true - ); + // Evaluate the sub-rule + const passed = this.#evaluateRule(candidate, criteria, false, true); - if (passed) this.#subRuleResults.push(subRule.result); + if (passed) this.#nestedResults.push(candidate.result); + }); } /** diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 8370326..af5e799 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -1,27 +1,16 @@ -import { - Rule, - SubRule, - Condition, - Constraint, - CriteriaRange, - ConditionType, - IntrospectionResult, -} from "../types"; import { Logger } from "./logger"; -import { RuleTypeError } from "../errors"; import { ObjectDiscovery } from "./object-discovery"; +import { Rule, Condition, Constraint, IntrospectionResult } from "../types"; -interface IntrospectionStep { - parentType?: ConditionType; - currType: ConditionType; - depth: number; - option: Record; - changes?: { key: string; value: unknown }[]; +interface SubRuleResult { + parent?: Condition; + subRule: Condition; } -interface SubRuleResult { - parent: Condition; - subRule: SubRule; +interface ConditionResult { + values?: Map; + stop: boolean; + void: boolean; } /** @@ -31,671 +20,1197 @@ interface SubRuleResult { */ export class Introspector { #objectDiscovery: ObjectDiscovery = new ObjectDiscovery(); - #steps: IntrospectionStep[]; - /** - * Given a rule, checks the constraints and conditions to determine - * the possible range of input criteria which would be satisfied by the rule. - * The rule must be a granular rule to be introspected. - * @param rule The rule to evaluate. - * @throws RuleTypeError if the rule is not granular - */ - introspect(rule: Rule): IntrospectionResult { - // The ruleset needs to be granular for this operation to work - if (!this.#objectDiscovery.isGranular(rule)) { - throw new RuleTypeError("Introspection requires granular rules."); + introspect( + rule: Rule, + constraint: Omit, + subjects: string[] + ): IntrospectionResult { + // We care about all the possible values for the subjects which will satisfy + // the rule if the rule is tested against the constraint provided. + + // To proceed we must first clone the rule (to avoid modifying the original) + rule = JSON.parse(JSON.stringify(rule)); + + // First step is to simplify the rule: + // 1. Make sure the rule conditions is an array. + // 2. Convert any 'none' conditions to an 'all' and reverse operators of all children till the bottom. + // 3. Remove all constraints which are not relevant to the subjects provided. + rule.conditions = this.#asArray(rule.conditions); + for (let i = 0; i < rule.conditions.length; i++) { + rule.conditions[i] = this.#reverseNoneToAll(rule.conditions[i]); + rule.conditions[i] = this.#removeIrrelevantConstraints( + rule.conditions[i], + [...subjects, constraint.field] + ); } - // Find any conditions which contain sub-rules - let subRuleResults: SubRuleResult[] = []; - for (const condition of this.#asArray(rule.conditions)) { - subRuleResults = subRuleResults.concat( - this.#findSubRules(condition, condition) - ); + // We then need to extract all sub-rules from the main rule + let subRules: SubRuleResult[] = []; + for (const condition of rule.conditions) { + subRules = subRules.concat(this.#extractSubRules(condition)); } - let results = this.#introspectRule(rule); + // We then create a new version of the rule without any of the sub-rules + const conditions: Condition[] = []; + for (let i = 0; i < rule.conditions.length; i++) { + conditions.push(this.#removeAllSubRules(rule.conditions[i])); + } - // Construct a new rule from each sub-rule result - for (const subRuleResult of subRuleResults) { - const res = this.#flatten(subRuleResult.parent); - for (const condition of this.#asArray(subRuleResult.subRule.conditions)) { - res.all.push(condition); + subRules.forEach((rule) => { + if (!rule.parent) { + conditions.push(rule.subRule); + return; } - // console.log( - // `Rule ${subRuleResult.subRule.result}:`, - // JSON.stringify({ - // conditions: { - // ...res, - // result: subRuleResult.subRule.result, - // }, - // }) - // ); - - results = results.concat( - this.#introspectRule({ - conditions: { - ...res, - result: subRuleResult.subRule.result, - }, - }) - ); - } + const result = rule.subRule.result; + delete rule.parent.result; + delete rule.subRule.result; - return { - results, - ...("default" in rule && undefined !== rule.default - ? { default: rule.default } - : {}), - }; - } + conditions.push({ all: [rule.parent, rule.subRule], result }); + }); - /** - * Runs the introspection process on a rule to determine the possible range of input criteria - * @param rule The rule to introspect. - */ - #introspectRule(rule: Rule): CriteriaRange[] { - // Initialize a clean steps array each time we introspect - this.#steps = []; - const conditions = this.#asArray(rule.conditions); - - // Then we map each result to the condition that produces - // it to create a map of results to conditions - const conditionMap = new Map(); + // console.log("subRules", JSON.stringify(subRules)); + // console.log("conditions", JSON.stringify(conditions)); + + // At this point the search becomes as follows: What are the possible values for the + // subjects which will satisfy the rule if the rule is tested against the constraint provided. + + const results = {}; + + // We introspect the conditions to determine the possible values for the subjects for (const condition of conditions) { - const data = conditionMap.get(condition.result) ?? []; - if (!data.length) conditionMap.set(condition.result, data); + const { values } = this.#introspectConditions(condition, constraint); + if (!values) continue; - data.push(condition); - } + const key = condition.result ?? "default"; + + // Merge the results maintaining the uniqueness of the values + for (const [field, constraints] of values.entries()) { + if (!subjects.includes(field)) continue; - // Using this information we can build the skeleton of the introspected criteria range - const criteriaRange: CriteriaRange[] = []; - for (const result of conditionMap.keys()) { - criteriaRange.push({ result, options: [] }); + const set = new Set([...(results[field] ?? [])]); + for (const constraint of constraints) { + set.add({ value: constraint.value, operator: constraint.operator }); + } + + if (set.size) { + results[key] = {}; + results[key][field] = Array.from(set); + } + } } - // We need to populate each item in the `criteriaRange` with - // the possible range of input values (in the criteria) which - // would resolve to the given result. Rules are recursive. - return this.#resolveCriteriaRanges(criteriaRange, conditionMap); + return results; } /** - * Recursively finds all sub-rules in a condition. - * @param condition The condition to search. - * @param root The root condition which holds the condition to search. - * @param results The results array to populate. + * Reverses all 'none' conditions to 'all' and flips the operators of all children. + * @param condition The condition to reverse. + * @param shouldFlip A flag to indicate if the operators should be flipped. */ - #findSubRules( + #reverseNoneToAll( condition: Condition, - root: Condition, - results: SubRuleResult[] = [] - ): SubRuleResult[] { - // Find the type of the condition + shouldFlip: boolean = false + ): Condition { const type = this.#objectDiscovery.conditionType(condition); + if ("none" === type) shouldFlip = !shouldFlip; // Iterate each node in the condition - for (const node of condition[type]) { - if (this.#objectDiscovery.isSubRule(node)) { - results.push({ - parent: this.#removeSubRule(node.rule, root), - subRule: { - conditions: this.#stripAllSubRules(node.rule.conditions), - result: node.rule.result, - }, - }); + for (let i = 0; i < condition[type].length; i++) { + let node = condition[type][i]; - // Recursively find sub-rules within the sub-rule - for (const condition of this.#asArray(node.rule.conditions)) { - results = this.#findSubRules(condition, root, results); - } + // If the node is a condition, check if we need to reverse it + if (shouldFlip && this.#objectDiscovery.isConstraint(node)) { + node = this.#flipConstraintOperator(node as Constraint); } - // Recursively find sub-rules within the condition + // If the node is a condition, recurse if (this.#objectDiscovery.isCondition(node)) { - results = this.#findSubRules(node, root, results); + node = this.#reverseNoneToAll(node as Condition, shouldFlip); } } - return results; + if (shouldFlip) { + if ("none" === type) { + condition["all"] = condition[type]; + delete condition[type]; + } + + if ("any" === type) { + condition["all"] = condition[type]; + delete condition[type]; + } + } + + return this.#stripNullProps(condition); } /** - * Remove the provided sub-rule needle from the haystack condition - * @param needle The sub-rule to remove. - * @param haystack The condition to search in and remove the sub-rule from. + * Removes all constraints which are not relevant to the subjects provided. + * @param condition The condition to remove irrelevant constraints from. + * @param toKeep The subjects to keep. */ - #removeSubRule(needle: SubRule, haystack: Condition): Condition { - // Clone the root condition so that we can modify it - const clone = JSON.parse(JSON.stringify(haystack)); + #removeIrrelevantConstraints( + condition: Condition, + toKeep: string[] + ): Condition { + const type = this.#objectDiscovery.conditionType(condition); - // Find the type of the condition - const type = this.#objectDiscovery.conditionType(clone); + // Iterate each node in the condition + for (let i = 0; i < condition[type].length; i++) { + let node = condition[type][i]; - // Iterate over each node in the condition - for (let i = 0; i < clone[type].length; i++) { - const node = clone[type][i]; + // If the node is a condition, check if we need to reverse it + const isConstraint = this.#objectDiscovery.isConstraint(node); + if (isConstraint && !toKeep.includes(node["field"])) { + delete condition[type][i]; + } // If the node is a condition, recurse if (this.#objectDiscovery.isCondition(node)) { - clone[type][i] = this.#removeSubRule(needle, node); - continue; + node = this.#removeIrrelevantConstraints(node as Condition, toKeep); } + } - // If the node is a sub-rule - if (this.#objectDiscovery.isSubRule(node)) { - if (!this.existsIn(needle, node.rule.conditions)) { - clone[type].splice(i, 1); - continue; - } + return this.#stripNullProps(condition); + } - // Otherwise, recurse into the sub-rule - const conditions = this.#asArray(node.rule.conditions); - for (let j = 0; j < conditions.length; j++) { - clone[type][i].rule.conditions[j] = this.#removeSubRule( - needle, - conditions[j] - ); - } - } + /** + * Flips the operator of a constraint. + * @param c The constraint to flip the operator of. + */ + #flipConstraintOperator(c: Constraint): Constraint { + if ("==" === c.operator) { + c.operator = "!="; + return c; + } + if ("!=" === c.operator) { + c.operator = "=="; + return c; + } + if (">" === c.operator) { + c.operator = "<="; + return c; + } + if ("<" === c.operator) { + c.operator = ">="; + return c; + } + if (">=" === c.operator) { + c.operator = "<"; + return c; + } + if ("<=" === c.operator) { + c.operator = ">"; + return c; + } + if ("in" === c.operator) { + c.operator = "not in"; + return c; + } + if ("not in" === c.operator) { + c.operator = "in"; + return c; + } + if ("contains" === c.operator) { + c.operator = "not contains"; + return c; + } + if ("not contains" === c.operator) { + c.operator = "contains"; + return c; + } + if ("contains any" === c.operator) { + c.operator = "not contains any"; + return c; + } + if ("not contains any" === c.operator) { + c.operator = "contains any"; + return c; + } + if ("matches" === c.operator) { + c.operator = "not matches"; + return c; + } + if ("not matches" === c.operator) { + c.operator = "matches"; + return c; } - return clone; + return c; } /** - * Checks if a sub-rule exists in a given haystack. - * @param needle The sub-rule to search for. - * @param haystack The condition to search in. - * @param found A flag to indicate if the sub-rule has been found. + * Removes all null properties from an object. + * @param obj The object to remove null properties from. + * @param defaults The default values to remove. */ - existsIn( - needle: SubRule, - haystack: unknown, - found: boolean = false - ): boolean { - if (found) return true; - - // Otherwise, recurse into the sub-rule - for (const node of this.#asArray(haystack)) { - // If the node is a sub-rule - if (this.#objectDiscovery.isSubRule(node)) { - // Check if it is the sub-rule we are looking for - if (JSON.stringify(needle) === JSON.stringify(node.rule)) return true; - // Otherwise, recurse into the sub-rule - found = this.existsIn(needle, node.rule.conditions, found); + #stripNullProps( + obj: Record, + defaults: any[] = [undefined, null, NaN, ""] + ) { + if (defaults.includes(obj)) return; + + if (Array.isArray(obj)) + return obj + .map((v) => + v && typeof v === "object" ? this.#stripNullProps(v, defaults) : v + ) + .filter((v) => !defaults.includes(v)); + + return Object.entries(obj).length + ? Object.entries(obj) + .map(([k, v]) => [ + k, + v && typeof v === "object" ? this.#stripNullProps(v, defaults) : v, + ]) + .reduce( + (a, [k, v]) => (defaults.includes(v) ? a : { ...a, [k]: v }), + {} + ) + : obj; + } + + /** + * Extracts all sub-rules from a condition. + * @param condition The condition to extract sub-rules from. + * @param results The sub-conditions result set + * @param root The root condition which holds the condition to extract sub-rules from. + */ + #extractSubRules( + condition: Condition, + results: SubRuleResult[] = [], + root?: Condition + ): SubRuleResult[] { + if (!root) root = condition; + + // Iterate each node in the condition + const type = this.#objectDiscovery.conditionType(condition); + for (const node of condition[type]) { + // If the node is a sub-rule we need to extract it, using the condition as it's parent + if (this.#objectDiscovery.isConditionWithResult(node)) { + results.push({ + parent: this.#removeAllSubRules(root), + subRule: this.#removeAllSubRules(node), + }); + + // Recursively find sub-rules in the sub-rule + for (const element of this.#asArray(node)) { + // Ignore constraints + if (!this.#objectDiscovery.isCondition(element)) continue; + results = this.#extractSubRules(element, results, root); + } + + // Do not re-process as a condition + continue; } // If the node is a condition, recurse if (this.#objectDiscovery.isCondition(node)) { - const type = this.#objectDiscovery.conditionType(node); - found = this.existsIn(needle, node[type], found); + results = this.#extractSubRules(node, results, root); } } - return found; + return results; } /** - * Removes all sub-rules from a condition or array of conditions. - * @param conditions The conditions to remove sub-rules from. + * Removes all subrules from the provided condition. + * @param haystack The condition to search in and remove all sub-rules from. */ - #stripAllSubRules(conditions: R): R | undefined { - // Clone the conditions array so that we can modify it - const clone = JSON.parse(JSON.stringify(this.#asArray(conditions))); - - // Iterate over each condition in the array - for (let i = 0; i < clone.length; i++) { - // Find the type of the condition - const type = this.#objectDiscovery.conditionType(clone[i]); - - // Iterate over each node in the condition - for (let j = 0; j < clone[i][type].length; j++) { - const node = clone[i][type][j]; - - // If the node is a condition, recurse - if (this.#objectDiscovery.isCondition(node)) { - const res = this.#stripAllSubRules(node); - if (res) clone[i][type][j] = res; - else clone[i][type].splice(j, 1); - } + #removeAllSubRules(haystack: Condition): Condition { + // Clone the condition so that we can modify it + const clone = JSON.parse(JSON.stringify(haystack)); + + // Iterate over each node in the condition + const type = this.#objectDiscovery.conditionType(clone); + for (let i = 0; i < clone[type].length; i++) { + // Check if the current node is a sub-rule + if (this.#objectDiscovery.isConditionWithResult(clone[type][i])) { + // Remove the node from the cloned object + clone[type].splice(i, 1); + + // If the node is now empty, we can prune it + if (Array.isArray(clone[type]) && !clone[type].length) return null; + continue; + } - // If the node is a sub-rule, remove it - if (this.#objectDiscovery.isSubRule(node)) clone[i][type].splice(j, 1); + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(clone[type][i])) { + clone[type][i] = this.#removeAllSubRules(clone[type][i]); } } - return Array.isArray(conditions) ? (clone as R) : (clone[0] as R); + return this.#stripNullProps(clone); } /** - * Flattens a condition or set of conditions into a single object. - * @param conditions The conditions to flatten. - * @param result The result object to populate. + * Converts a value to an array if it is not already an array. + * @param value The value to convert. */ - #flatten( - conditions: Condition | Condition[], - result: Condition = { all: [] } - ): Condition { - // Clone the conditions array so that we can modify it - const clone = JSON.parse(JSON.stringify(this.#asArray(conditions))); + #asArray(value: R | R[]): R[] { + return Array.isArray(value) ? value : [value]; + } - for (const condition of clone) { - const items = []; + /** + * Extracts all the possible combinations of criteria from the condition which are + * self-consistent to the condition passing. + * @param condition The condition to introspect. + * @param input The constraint passed as an input to the introspection. + * @param parentType The type of the parent condition. + * @param parentResults The parent condition results. + * @param depth The current recursion depth. + */ + #introspectConditions( + condition: Condition, + input: Omit, + parentType: keyof Condition = null, + parentResults: Map = new Map(), + depth: number = 0 + ): ConditionResult { + // Prepare the lists + const conditions: Condition[] = []; + const groupedConst: Map = new Map(); + + // Iterate over each node in the condition + const type = this.#objectDiscovery.conditionType(condition); + for (const node of condition[type]) { + if (this.#objectDiscovery.isCondition(node)) { + // Process the 'all' conditions before the 'any' conditions + if ("all" === this.#objectDiscovery.conditionType(node)) + conditions.unshift(node); + else conditions.push(node); + } + if (this.#objectDiscovery.isConstraint(node)) { + this.#appendResult(groupedConst, node); + } + } - const type = this.#objectDiscovery.conditionType(condition); - for (const node of condition[type]) { - if (this.#objectDiscovery.isSubRule(node)) { - result = this.#flatten(node.rule.conditions, result); - continue; + const gap = " ".repeat(depth); + const msg = + 0 === depth + ? `\nIntrospecting "${Logger.bold(type)}" condition` + : `${gap}--> "${Logger.bold(type)}" condition`; + Logger.debug(msg); + + // Prepare the local results + let clearLocal = false; + let results: Map = new Map(); + + // Iterate over all grouped constraints + for (const [field, constraints] of groupedConst.entries()) { + // Prepare the candidates for this field type + let candidates = results.get(field) ?? []; + + if (clearLocal) continue; + + // Test the constraints and prepare the local results + //////////////////////////////////////////////////////////// + for (const c of constraints) { + // Append to local results + if ("any" === type) { + const col = Logger.color(c.field, "g"); + const val = Logger.color(c.value, "y"); + const msg = ` ${gap}+ Adding local '${col}'${c.operator}'${val}'`; + Logger.debug(msg, `(${Logger.color("pass", "g")})`); + + candidates.push(c); } - items.push(node); + if ("all" === type) { + if (!this.test(candidates, input, c, depth)) { + candidates = []; + clearLocal = true; + Logger.debug(` ${gap}- Clearing local candidates...`); + break; + } + + const col = Logger.color(c.field, "g"); + const val = Logger.color(c.value, "y"); + const msg = ` ${gap}+ Adding local '${col}'${c.operator}'${val}'`; + Logger.debug(msg, `(${Logger.color("pass", "g")})`); + + candidates.push(c); + } } - result.all.push({ [type]: items }); + // Store the updated candidates into the local results + results.set(field, candidates); } - return result; - } + if (clearLocal) results = new Map(); - /** - * Populates the criteria range options with the possible range of input values - * @param criteriaRanges The criteria range to populate. - * @param conditionMap The map of results to conditions. - * @param parentType The type of the parent condition. - * @param depth The current recursion depth. - */ - #resolveCriteriaRanges( - criteriaRanges: CriteriaRange[], - conditionMap: Map, - parentType: ConditionType = null, - depth: number = 0 - ): CriteriaRange[] { - // For each set of conditions which produce the same result - for (const [result, conditions] of conditionMap) { - // For each condition in that set - for (const condition of conditions) { - const type = this.#objectDiscovery.conditionType(condition); - if (!type) continue; - - Logger.debug(`\nIntrospector: Introspecting result "${result}"`); - - // Find the criteria range object for the result - let criteriaRangeItem = criteriaRanges.find((c) => c.result == result); - criteriaRangeItem = this.#populateCriteriaRangeOptions( - criteriaRangeItem, - condition, - depth, - parentType - ); - - // Iterate over each property of the condition - for (const node of condition[type]) { - if (this.#objectDiscovery.isCondition(node)) { - const condition = node as Condition; - criteriaRangeItem = this.#resolveCriteriaRanges( - [criteriaRangeItem], - new Map([[result, [condition]]]), - type, - depth + 1 - ).pop(); + //////////////////////////////////////////////////////////// + + // Merge the local results with the parent results + //////////////////////////////////////////////////////////// + if (null === parentType) { + for (const [_, candidates] of results.entries()) { + for (const c of candidates) this.#appendResult(parentResults, c); + } + } + + if ("any" === parentType) { + if ("any" === type) { + for (const [_, candidates] of results.entries()) { + for (const c of candidates) this.#appendResult(parentResults, c); + } + } + + if ("all" === type) { + for (const [_, candidates] of results.entries()) { + if (!candidates.length) { + Logger.debug(`${gap}X Exiting...`); + return { values: parentResults, stop: true, void: false }; } - // Update the original set of criteria range objects - // with the updated criteria range object - const index = criteriaRanges.findIndex((c) => c.result == result); - criteriaRanges[index] = criteriaRangeItem; + for (const c of candidates) this.#appendResult(parentResults, c); } } } - return criteriaRanges; - } + if ("all" === parentType) { + if ("any" === type) { + const valid = []; + for (const [field, candidates] of results.entries()) { + for (const c of candidates) { + const parentRes = parentResults.get(field) ?? []; + if (this.test(parentRes, input, c, depth)) valid.push(c); + } + } - /** - * Updates a criteria range entry based on the constraint and condition type. - * @param criteriaRange The criteria range entry to update. - * @param condition The condition to update the criteria range entry with. - * @param depth The current recursion depth. - * @param parentType The type of the parent condition. - */ - #populateCriteriaRangeOptions( - criteriaRange: CriteriaRange, - condition: Condition, - depth: number, - parentType?: ConditionType - ): CriteriaRange { - const type = this.#objectDiscovery.conditionType(condition); - const options = new Map>(); + if (!valid.length) { + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); + return { values: parentResults, stop: true, void: true }; + } - for (const node of condition[type]) { - if (!this.#objectDiscovery.isConstraint(node)) { - continue; + for (const c of valid) this.#appendResult(parentResults, c); } - const constraint = node as Constraint; + if ("all" === type) { + // We assume all constraints are valid until proven otherwise, + // However if the list is empty we must say that no constraint has passed. + let allPass = Array.from(results.values()).flat().length > 0; - Logger.debug( - `Introspector: Processing "${constraint.field} (${constraint.operator})" in "${type}" condition` - ); + for (const [field, candidates] of results.entries()) { + for (const c of candidates) { + const parentRes = parentResults.get(field) ?? []; + if (!this.test(parentRes, input, c, depth)) allPass = false; + } + } + + if (!allPass) { + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); + return { values: parentResults, stop: true, void: true }; + } - // Check if we already have an entry with the same field - // and if so, update it instead of creating a new one - let option = options.get(constraint.field) ?? {}; - option = this.#updateCriteriaRangeOptions(option, constraint, type); - options.set(constraint.field, option); - } - - if (["any", "none"].includes(type)) { - Array.from(options.values()).forEach((option) => { - criteriaRange = this.#addOptionToCriteriaRange( - type, - parentType, - criteriaRange, - option, - depth - ); - - // Debug the last introspection - Logger.debug("Introspector: Step complete", this.lastStep); - }); - } - - if ("all" === type) { - criteriaRange = this.#addOptionToCriteriaRange( - type, - parentType, - criteriaRange, - Array.from(options.values()).reduce((prev, curr) => { - for (const [key, value] of Object.entries(curr)) { - prev[key] = prev.hasOwnProperty(key) - ? [...new Set([prev[key], value].flat())] - : value; + for (const [_, candidates] of results.entries()) { + for (const c of candidates) { + this.#appendResult(parentResults, c); } + } + } + } + //////////////////////////////////////////////////////////// + + // Log the results + //////////////////////////////////////////////////////////// + for (const [k, v] of parentResults.entries()) { + const values = []; + for (const c of v) { + values.push(`${c.operator}${Logger.color(c.value, "y")}`); + } - return prev; - }, {}), - depth - ); + const msg = ` ${gap}${Logger.color("* Candidates", "m")} `; + Logger.debug(`${msg}${Logger.color(k, "g")}: [${values.join(", ")}]`); + } + //////////////////////////////////////////////////////////// + + // Sanitize the results + //////////////////////////////////////////////////////////// + for (const [field, constraints] of parentResults.entries()) { + parentResults.set(field, this.sanitize(constraints, depth)); + } + //////////////////////////////////////////////////////////// + + // Iterate over all conditions + //////////////////////////////////////////////////////////// + for (const c of conditions) { + // Introspect the condition and append the results to the parent results + const d = depth + 1; + const res = this.#introspectConditions(c, input, type, parentResults, d); + + if (res.void) parentResults = new Map(); + else parentResults = res.values; - // Debug the last introspection - Logger.debug("Introspector: Step complete", this.lastStep); + if (res.stop) + return { values: parentResults, stop: res.stop, void: res.void }; } + ////////////////////////////////////////// - return criteriaRange; + return { values: parentResults, stop: false, void: false }; } /** - * Updates a criteria range option based on the constraint and condition type. - * @param option The option to update. - * @param constraint The constraint to update the option with. - * @param type The current condition type. + * Appends a constraint to the provided map based placing the constraint in a + * group based on its field. + * @param map The map to append the constraint to. + * @param c The constraint to append. */ - #updateCriteriaRangeOptions( - option: Record, - constraint: Constraint, - type: ConditionType - ): Record { - // We need to clone the constraint because we will be modifying it - const c = { ...constraint }; - - // We can consider a 'None' as a not 'All' and flip all the operators - // To be done on any 'None' type condition or on any child - // of a 'None' type condition. - if (type === "none" || this.#isCurrentStepChildOf("none")) { + #appendResult(map: Map, c: Constraint): void { + const temp = map.get(c.field) ?? []; + map.set(c.field, temp); + + // Do not add duplicate constraints + if (temp.some((t) => JSON.stringify(t) === JSON.stringify(c))) return; + + temp.push(c); + } + + /** + * Given a list of valid candidates and the input used in the introspection, this method + * tests a new constraint (item) returning true if the item is self-consistent with the + * candidates and the input. + * + * Testing happens by considering each item in the list as linked by an AND + * @param candidates The result candidates to test against. + * @param input The constraint which was input to the introspection. + * @param item The constraint item to test against the candidates. + * @param depth The current recursion depth. + */ + protected test( + candidates: Constraint[], + input: Omit, + item: Constraint, + depth: number + ): boolean { + // Filter out results which do not match the field of the constraint + candidates = candidates.filter((r) => r.field === item.field); + + // Add the input constraint to the results (if it also matches the field) + if (input.field === item.field) { + candidates.push({ ...input, operator: "==" }); + } + + // Test that the constraint does not breach the results + // The test constraint needs to be compared against all the results + // and has to pass all the tests to be considered valid + let result = true; + for (const c of candidates) { + let ops: any; + + // Extract the item properties to test with + const { value, operator } = item; + switch (c.operator) { case "==": - c.operator = "!="; + /** + * c = (L == 500) + * L == 500 + * L != !500 + * L >= 500 + * L <= 500 + * L > 499 + * L < 501 + * L IN [500] + * L NOT IN [501, 502] + */ + + // Must be equal to the value + if ("==" === operator && value !== c.value) result = false; + + // Item value must allow for constraint value to exist in item value range + if ("<=" === operator && value < c.value) result = false; + if (">=" === operator && value > c.value) result = false; + + // Item value must allow for constraint value to exist in item value range + if ("<" === operator && value <= c.value) result = false; + if (">" === operator && value >= c.value) result = false; + + // Item value cannot be equal to constraint value + if ("!=" === operator && value === c.value) result = false; + + // One of the values in the item must match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val === c.value)) + result = false; + } + + // None of the values in the item must match the candidate value + if ("not in" === operator) { + if (this.#asArray(value).some((val) => val === c.value)) + result = false; + } break; case "!=": - c.operator = "=="; + /** + * c = (L != 500) + * L == !500 + * L != any + * L >= any + * L <= any + * L > any + * L < any + * L IN [500, 502] + * L NOT IN [499,500] + */ + + // Must be different + if ("==" === operator && value === c.value) result = false; + + // // Always pass + // ops = ["!=", ">", ">=", "<", "<=", "not in"]; + // if (ops.includes(operator)) result = true; + + // One of the values in the item must NOT match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val !== c.value)) + result = false; + } break; case ">": - c.operator = "<="; + /** + * c = (L > 500) + * L == 501↑ + * L != any + * L >= any + * L <= 501↑ + * L > any + * L < 502↑ + * IN [501, 502] + * NOT IN [501, 502] + */ + + // Always pass ["!=", ">", ">=", "not in"] + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value <= c.value) result = false; + + if ("<" === operator && Number(value) <= Number(c.value) + 2) + result = false; + + // One of the values in the item must match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val > c.value)) + result = false; + } break; case "<": - c.operator = ">="; + /** + * c = (L < 500) + * L == 499↓ + * L != any + * L >= 499↓ + * L <= any + * L > 498↓ + * L < any + * IN [499, 500] + */ + + // Always pass ["!=", "<", "<=", "not in"] + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value >= c.value) result = false; + + if (">" === operator && Number(value) >= Number(c.value) - 2) + result = false; + + // One of the values in the item must match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val < c.value)) + result = false; + } break; case ">=": - c.operator = "<"; + /** + * c = (L >= 500) + * L == 500↑ + * L != any + * L >= any + * L <= 500↑ + * L > any + * L < 501↑ + * L IN [500, 501] + */ + + // Always pass ["!=", ">=", ">", "not in"] + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value < c.value) result = false; + + if ("<" === operator && Number(value) < Number(c.value) + 1) + result = false; + + // One of the values in the item must match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val >= c.value)) + result = false; + } break; case "<=": - c.operator = ">"; + /** + * c = (L <= 500) + * L == 500↓ + * L != any + * L >= 500↓ + * L <= any + * L > 499↓ + * L < any + */ + + // Always pass ["!=", "<=", "<", "not in"] + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value > c.value) result = false; + + if (">" === operator && Number(value) > Number(c.value) - 1) + result = false; + + // One of the values in the item must match the candidate value + if ("in" === operator) { + if (!this.#asArray(value).some((val) => val <= c.value)) + result = false; + } break; case "in": - c.operator = "not in"; + /** + * c = (L [500,501) + * IN [500, 502] + * NOT IN [499, 500] + */ + result = false; + + // At least 1 item from list must pass + // For each item run the same checks as for the '==' operator + for (const subVal of this.#asArray(c.value)) { + // Must be equal to the value + if ("==" === operator && value === subVal) { + result = true; + } + + // Item value must allow for constraint value to exist in item value range + if ("<=" === operator && value >= subVal) { + result = true; + } + if (">=" === operator && value <= subVal) { + result = true; + } + + // Item value must allow for constraint value to exist in item value range + if ("<" === operator && value > subVal) { + result = true; + } + if (">" === operator && value < subVal) { + result = true; + } + + // Item value cannot be equal to constraint value + if ("!=" === operator && value !== subVal) { + result = true; + } + } + + const inValues = this.#asArray(c.value); + + // One of the values in the item must match any candidate values + if ("in" === operator) { + if (this.#asArray(value).some((val) => inValues.includes(val))) + result = true; + } + + // One of the values in the item must NOT match any candidate values + if ("not in" === operator) { + if (this.#asArray(value).some((val) => !inValues.includes(val))) + result = true; + } break; case "not in": - c.operator = "in"; - break; - case "contains": - c.operator = "not contains"; - break; - case "not contains": - c.operator = "contains"; - break; - case "contains any": - c.operator = "not contains any"; - break; - case "not contains any": - c.operator = "contains any"; - break; - case "matches": - c.operator = "not matches"; - break; - case "not matches": - c.operator = "matches"; - break; - } - } - - if (!option.hasOwnProperty(c.field)) { - // When condition is an all, we need to create a new object in the criteria range - // options and add all the possible inputs which would satisfy the condition. - if (type === "all" || type === "none") { - switch (c.operator) { - case "==": - case "in": - option[c.field] = c.value; - break; - default: - option[c.field] = { - operator: c.operator, - value: c.value, - }; - break; - } + /** + * c = (L NOT IN [500,501) + * IN [499, 501] + * NOT IN [500, 499] + */ + result = true; + + // All items from list must pass + for (const subVal of this.#asArray(c.value)) { + // Must be different + if ("==" === operator && value === subVal) { + result = false; + } + } - return option; - } + const notInValues = this.#asArray(c.value); - // When condition is an any, we need to create a new object in the criteria range - // options for each criterion in the condition and add all the possible inputs - // which would satisfy the criterion. - if ("any" === type) { - switch (c.operator) { - case "==": - case "in": - return { [c.field]: c.value }; - default: - return { - [c.field]: { - operator: c.operator, - value: c.value, - }, - }; - } + // One of the values in the item must NOT match any candidate values + if ("in" === operator) { + result = false; + if (this.#asArray(value).some((val) => !notInValues.includes(val))) + result = true; + } + break; + // case "contains": + // break; + // case "not contains": + // break; + // case "contains any": + // break; + // case "not contains any": + // break; + // case "matches": + // break; + // case "not matches": + // break; } } - let value = c.value; - if (c.operator !== "==") { - value = { operator: c.operator, value: c.value }; - } + // Prepare the log + const gap = " ".repeat(depth); + const col = Logger.color(item.field, "g"); + const val = Logger.color(item.value, "y"); + const pass = Logger.color("pass", "g"); + const fail = Logger.color("fail", "r"); - option[c.field] = [...new Set([option[c.field], value].flat())]; + const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; + Logger.debug(msg, `(${result ? pass : fail})`); - return option; + // Return the result + return result; } /** - * Adds an option to a criteria range entry based on the condition type and parent type. - * @param currType The current condition type. - * @param parentType The type of the parent condition. - * @param entry The criteria range entry to update. - * @param option The option to update the criteria range entry with. + * Takes a list of constraints which represent the possible values for a field which satisfy a rule + * and sanitizes the list to remove any constraints which are redundant. This method will convert the + * list of constraints to the smallest possible list of constraints which still represent the same + * possible values for the field. + * + * Sanitization happens by considering each item in the list as linked by an OR + * @param constraints The constraints to sanitize. * @param depth The current recursion depth. */ - #addOptionToCriteriaRange( - currType: ConditionType, - parentType: ConditionType, - entry: CriteriaRange, - option: Record, - depth: number - ): CriteriaRange { - const lastIdx = entry.options.length - 1; - - // We create new objects in the options array - if (["all", "none"].includes(currType) && parentType === "any") { - // If we encounter this pair in a deeply nested condition we need to clone - // the last option and add the options from the all by either appending - // them if the key is new, or replacing the 'any' with the new values. - if (depth > 1) { - // We should start based on the last option added - const baseOption = { ...entry.options[entry.options.length - 1] }; - - // If the previous step added anything to the base option then we must first - // remove these changes. For condition types of 'Any' or 'None' a step - // is created for each property in the condition. Therefore, we must - // remove the changes from step(s) which are at the same depth of - // the last step. - if (this.lastStep?.changes && this.lastStep.changes.length) { - const depth = this.lastStep.depth; - - const steps = [...this.#steps]; - let step = steps.pop(); - - while (step?.depth === depth) { - for (const change of step.changes) { - if (baseOption[change.key] === change.value) { - delete baseOption[change.key]; - } - - if (Array.isArray(baseOption[change.key])) { - baseOption[change.key] = (baseOption[change.key] as []).filter( - (o) => - Array.isArray(change.value) - ? !change.value.includes(o) - : o != change.value - ); - } - } - step = steps.pop(); + protected sanitize(constraints: Constraint[], depth: number): Constraint[] { + // Create a results list which we can modify + let results = JSON.parse(JSON.stringify(constraints)); + + // Clone the constraints so that we can modify them in needed + for (const sub of JSON.parse(JSON.stringify(constraints))) { + const op = sub.operator; + const val = sub.value; + + // Clone the list and iterate again + for (const c of JSON.parse(JSON.stringify(constraints))) { + // If the clone and the subject are the same, skip + if (JSON.stringify(c) === JSON.stringify(sub)) continue; + + if (">=" === op) { + if ("==" === c.operator && c.value === val) { + return this.sanitize(this.#removeItem(c, results), depth); + } + + if (">" === c.operator) { + // >=500, >500+ (remove >500+) + if (c.value >= val) results = this.#removeItem(c, results); + // >=500, >499- (remove >=500) + if (c.value < val) results = this.#removeItem(sub, results); + + return this.sanitize(results, depth); + } + + if (">=" === c.operator) { + // >=500, >=500+ (remove >=500+) + if (c.value >= val) results = this.#removeItem(c, results); + // >=500, >=499- (remove >=500) + if (c.value < val) results = this.#removeItem(sub, results); + + return this.sanitize(results, depth); } } - for (const [key, value] of Object.entries(option)) { - baseOption[key] = baseOption.hasOwnProperty(key) - ? [...new Set([baseOption[key], value].flat())] - : value; + if ("<=" === op) { + if ("==" === c.operator && c.value === val) { + return this.sanitize(this.#removeItem(c, results), depth); + } + + if ("<" === c.operator) { + // <=500, <500- (remove <500-) + if (c.value <= val) results = this.#removeItem(c, results); + // <=500, <501+ (remove >=500) + if (c.value > val) results = this.#removeItem(sub, results); + + return this.sanitize(results, depth); + } + + if ("<=" === c.operator) { + // <=500, <500- (remove <500-) + if (c.value <= val) results = this.#removeItem(c, results); + // <=500, <501+ (remove >=500) + if (c.value > val) results = this.#removeItem(sub, results); + + return this.sanitize(results, depth); + } } - this.#steps.push({ - parentType, - currType, - depth, - option: baseOption, - }); + if (">" === op) { + if ("==" === c.operator && c.value > val) { + return this.sanitize(this.#removeItem(c, results), depth); + } - Logger.debug( - `Introspector: + new option to criteria range based on last root parent` - ); + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, operator: ">=" }); - entry.options.push(baseOption); - return entry; - } + return this.sanitize(results, depth); + } + + if (">" === c.operator && c.value >= val) { + return this.sanitize(this.#removeItem(c, results), depth); + } + } - this.#steps.push({ parentType, currType, depth, option }); - Logger.debug(`Introspector: + new option to criteria range`); + if ("<" === op) { + if ("==" === c.operator && c.value < val) { + return this.sanitize(this.#removeItem(c, results), depth); + } - entry.options.push(option); - return entry; - } + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, operator: "<=" }); + + return this.sanitize(results, depth); + } - // We add these options onto the last object in the options array - if ( - ("any" === currType && "any" === parentType) || - ("any" === currType && ["all", "none"].includes(parentType)) || - (["all", "none"].includes(currType) && - ["all", "none"].includes(parentType)) - ) { - const changes: IntrospectionStep["changes"] = []; - for (const [key, value] of Object.entries(option)) { - entry.options[lastIdx][key] = entry.options[lastIdx].hasOwnProperty(key) - ? [...new Set([entry.options[lastIdx][key], value].flat())] - : value; + if ("<" === c.operator && c.value <= val) { + return this.sanitize(this.#removeItem(c, results), depth); + } + } + + if ("in" === op) { + // We join the two lists and remove the duplicates + if ("in" === c.operator) { + const set = new Set([ + ...this.#asArray(val), + ...this.#asArray(c.value), + ]); - changes.push({ key, value }); + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, value: [...set].sort() }); + + return this.sanitize(results, depth); + } + } + + if ("not in" === op) { + // We join the two lists and remove the duplicates + if ("not in" === c.operator) { + const set = new Set([ + ...this.#asArray(val), + ...this.#asArray(c.value), + ]); + + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, value: [...set].sort() }); + + return this.sanitize(results, depth); + } + } } - this.#steps.push({ - parentType, - currType, - depth, - option: entry.options[lastIdx], - changes, - }); + // If the list has 1 item, we convert to == + if (["in"].includes(op)) { + if (1 === this.#asArray(sub.value).length) { + results = this.#removeItem(sub, results); + results.push({ field: sub.field, operator: "==", value: val }); + + return this.sanitize(results, depth); + } + } - Logger.debug(`Introspector: Updating previous option with new values"`); + // If the list has 1 item, we convert to != + if (["not in"].includes(op)) { + if (1 === this.#asArray(sub.value).length) { + results = this.#removeItem(sub, results); + results.push({ field: sub.field, operator: "!=", value: val }); - return entry; + return this.sanitize(results, depth); + } + } } - this.#steps.push({ parentType, currType, depth, option }); - Logger.debug(`Introspector: + new option to criteria range`); + const gap = " ".repeat(depth); + const msg = ` ${gap}${Logger.color(`* Sanitized`, "b")}`; + const values = results.map( + (c: Constraint) => `${c.operator}${Logger.color(c.value, "m")}` + ); - entry.options.push(option); - return entry; + Logger.debug(`${msg} [${values.join(", ")}]`); + return results; } /** - * Checks if the current condition being introspected is the child - * of some parent condition with a given type. - * @param parentType The type of the parent condition. + * Remove the provided sub-rule needle from the haystack condition + * @param node The node to remove. + * @param haystack The condition to search in and remove the sub-rule from. */ - #isCurrentStepChildOf(parentType: ConditionType): boolean { - if (!this.#steps?.length) { - return false; - } + #removeNode(node: Record, haystack: Condition): Condition { + // Clone the condition so that we can modify it + const clone = JSON.parse(JSON.stringify(haystack)); - // Clone the steps array so that we can pop items off it - const steps = [...this.#steps]; - let step = steps.pop(); + // Iterate over each node in the condition + const type = this.#objectDiscovery.conditionType(clone); + for (let i = 0; i < clone[type].length; i++) { + // Check if the current node is the node we are looking for + if (JSON.stringify(clone[type][i]) == JSON.stringify(node)) { + // Remove the node from the cloned object + clone[type].splice(i, 1); - // Check ancestors until we reach the first root condition - while (step?.depth >= 0) { - if (step.currType === parentType || step.parentType === parentType) - return true; + // If the node is now empty, we can prune it + if (Array.isArray(clone[type]) && !clone[type].length) return null; + continue; + } - step = steps.pop(); + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(clone[type][i])) { + clone[type][i] = this.#removeNode(node, clone[type][i]); + } } - return false; + return this.#stripNullProps(clone); } /** - * Converts a value to an array if it is not already an array. - * @param value The value to convert. + * Remove the provided item needle from the haystack list + * @param needle The item to find and remove. + * @param haystack The list to search in and remove from. */ - #asArray(value: R | R[]): R[] { - return Array.isArray(value) ? value : [value]; + #removeItem(needle: any, haystack: R[]): R[] { + return haystack.filter( + (r: any) => JSON.stringify(r) !== JSON.stringify(needle) + ); } - /** Returns the last step in the introspection process. */ - get lastStep(): IntrospectionStep | null { - return this.#steps?.length ? this.#steps[this.#steps.length - 1] : null; + /** + * Checks if a condition has a constraint with the provided field. + * @param field The field to check for. + * @param haystack The condition to search in. + */ + #hasConstraintField(field: string, haystack: Condition): boolean { + // Iterate over each node in the condition + const type = this.#objectDiscovery.conditionType(haystack); + for (let i = 0; i < haystack[type].length; i++) { + const node = haystack[type][i]; + + // If the node is a constraint, check if it has the field we are looking for + if (this.#objectDiscovery.isConstraint(node) && node.field === field) { + return true; + } + + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(node)) { + return this.#hasConstraintField(field, node); + } + } + + return false; } + + /** + * Test Routine: + * We check each test item against the entire list of results. + * The test should be an AND format, this means that the test item AND each list item must be possible + * + * If the current type is “any” -> we just append the items + * If the current type is “all” -> we need to test the items against each other and all need to pass for them to be added. + * + * When merging up: + * If the parent type is “any” + * current type “any” -> we can just append the items + * current type “all” -> we can just append the items + * If the parent type is “all” + * current type “any” -> We need to test each item against the parent set, if any passes we add it in. The ones that fail do not get added. If none pass we stop + * current type “all” -> We need to test each item against the parent set, all must pass and all get added or none get added. If all do not pass we stop + * + * When to stop: + * If parent is null + * “all” None pass STOP + * “any” None pass do not stop + * + * If parent is any + * “all” None pass do not stop + * “any” None pass do not stop + * + * If parent is all + * “all” None pass STOP + * “any” None pass STOP + * + * Algorithm: + * -> Prepare all constraints as array + * -> Prepare all conditions as array + * -> Sort the array to push ‘all’ conditions to the start + * -> Check constraints first + * -> If parent is NULL + * -> if type is any + * -> add const fields/values to subject results (append) + * -> if type is all + * -> group const by field (and foreach group) + * -> test each const does not breach local group results or subject value + * -> if it passes + * -> add to local group results + * -> if fails + * -> empty the local/global results for all subjects + * -> stop processing any conditions under this node. + * -> if all pass send local to global + * + * -> If parent is any + * -> if type is any + * we continue adding const fields/values to subject results (append) + * -> if type is all + * -> group const by field (and foreach group) + * -> test each const does not breach local group results or subject value + * -> if it passes + * -> add to local group results + * -> if fails + * -> empty the local results for all subjects + * -> stop processing any conditions under this node. + * -> do not empty global results. + * -> if some pass (all will pass) + * -> add local to global results + * + * -> If parent is all + * -> if type is any + * -> group const by field (and foreach group) + * -> bal group results or subject value + * -> if passes + * -> add to global group results + * -> if fails + * -> do not add + * -> if all fail + * -> empty the local/global results for all subjects + * -> stop processing any conditions under this node. + * -> if type is all + * -> group const by field (and foreach group) + * -> test each const does not breach local group results or subject value + * -> if it passes + * -> add to local group results + * -> test against global results + * -> if it passes + * -> add to global group results + * -> if fails + * -> empty the global results for all subjects + * -> stop processing any conditions under this node. + * -> if fails + * -> empty the global results for all subjects + * -> stop processing any conditions under this node. + * + * -> Check conditions array + * -> recurse each condition + */ } diff --git a/src/services/logger.ts b/src/services/logger.ts index 5f19482..32adb50 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -3,4 +3,27 @@ export class Logger { if ("true" !== process.env.DEBUG) return; console.debug(...opts); } + + /** + * Formats text with color. + * @param text The text to colorize. + * @param color The color to apply. + */ + static color(text: unknown, color: "r" | "g" | "b" | "y" | "m"): string { + if ("r" === color) return `\x1b[31m${text}\x1b[0m`; + if ("g" === color) return `\x1b[32m${text}\x1b[0m`; + if ("y" === color) return `\x1b[33m${text}\x1b[0m`; + if ("b" === color) return `\x1b[34m${text}\x1b[0m`; + if ("m" === color) return `\x1b[35m${text}\x1b[0m`; + + return text.toString(); + } + + /** + * Formats text as bold. + * @param text The text to bold. + */ + static bold(text: unknown): string { + return `\x1b[1m${text}\x1b[0m`; + } } diff --git a/src/services/object-discovery.ts b/src/services/object-discovery.ts index 3945770..28efc89 100644 --- a/src/services/object-discovery.ts +++ b/src/services/object-discovery.ts @@ -1,4 +1,10 @@ -import { Rule, SubRule, Condition, Constraint, ConditionType } from "../types"; +import { + Rule, + Condition, + Constraint, + WithRequired, + ConditionType, +} from "../types"; export class ObjectDiscovery { /** @@ -40,34 +46,27 @@ export class ObjectDiscovery { * @param obj The object to check. */ isCondition(obj: unknown): obj is Condition { - return !this.isObject(obj) - ? false - : "any" in obj || "all" in obj || "none" in obj; + if (!this.isObject(obj)) return false; + return "any" in obj || "all" in obj || "none" in obj; } /** - * Checks an object to see if it is a valid constraint. + * Checks an object to see if it is a valid sub-rule. * @param obj The object to check. */ - isConstraint(obj: unknown): obj is Constraint { - return !this.isObject(obj) - ? false - : "field" in obj && "operator" in obj && "value" in obj; + isConditionWithResult( + obj: unknown + ): obj is WithRequired { + return this.isCondition(obj) && "result" in obj; } /** - * Checks an object to see if it is a valid sub-rule. + * Checks an object to see if it is a valid constraint. * @param obj The object to check. */ - isSubRule(obj: unknown): obj is { rule: SubRule } { + isConstraint(obj: unknown): obj is Constraint { if (!this.isObject(obj)) return false; - if (!("rule" in obj)) return false; - - return ( - this.isObject(obj.rule) && - "conditions" in obj.rule && - "result" in obj.rule - ); + return "field" in obj && "operator" in obj && "value" in obj; } /** diff --git a/src/services/rule-pilot.ts b/src/services/rule-pilot.ts index 1948ad4..ed05968 100644 --- a/src/services/rule-pilot.ts +++ b/src/services/rule-pilot.ts @@ -3,8 +3,8 @@ import { Builder } from "../builder"; import { RuleError } from "../errors"; import { Evaluator } from "./evaluator"; import { Introspector } from "./introspector"; -import { Rule, IntrospectionResult } from "../types"; import { Validator, ValidationResult } from "./validator"; +import { Rule, Constraint, IntrospectionResult } from "../types"; export class RulePilot { static #rulePilot = new RulePilot(); @@ -93,17 +93,23 @@ export class RulePilot { * the possible range of input criteria which would be satisfied by the rule. * * @param rule The rule to evaluate. + * @param constraint The constraint to introspect against. + * @param subjects The subjects to introspect for. * @throws RuleError if the rule is invalid * @throws RuleTypeError if the rule is not granular */ - introspect(rule: Rule): IntrospectionResult { + introspect( + rule: Rule, + constraint: Omit, + subjects: string[] + ): IntrospectionResult { // Before we proceed with the rule, we should validate it. const validationResult = this.validate(rule); if (!validationResult.isValid) { throw new RuleError(validationResult); } - return this.#introspector.introspect(rule); + return this.#introspector.introspect(rule, constraint, subjects); } /** @@ -151,11 +157,17 @@ export class RulePilot { * the possible range of input criteria which would be satisfied by the rule. * * @param rule The rule to introspect. + * @param constraint The constraint to introspect against. + * @param subjects The subjects to introspect for. * @throws RuleError if the rule is invalid * @throws RuleTypeError if the rule is not granular */ - static introspect(rule: Rule): IntrospectionResult { - return RulePilot.#rulePilot.introspect(rule); + static introspect( + rule: Rule, + constraint: Omit, + subjects: string[] + ): IntrospectionResult { + return RulePilot.#rulePilot.introspect(rule, constraint, subjects); } /** diff --git a/src/services/validator.ts b/src/services/validator.ts index d10382c..0cb51c8 100644 --- a/src/services/validator.ts +++ b/src/services/validator.ts @@ -72,9 +72,7 @@ export class Validator { ): ValidationResult { // Check to see if the condition is valid. const result = this.#isValidCondition(condition); - if (!result.isValid) { - return result; - } + if (!result.isValid) return result; // Set the type of condition. const type = this.#objectDiscovery.conditionType(condition); @@ -90,14 +88,20 @@ export class Validator { }; } + // Check if the condition is iterable + if (!condition[type].length) { + return { + isValid: false, + error: { + message: `The condition '${type}' should not be empty.`, + element: condition, + }, + }; + } + // Validate each item in the condition. for (const node of condition[type]) { - // If the object is a sub-rule, we should validate it as a new rule - if (this.#objectDiscovery.isSubRule(node)) { - return this.validate(node.rule); - } - - // Otherwise, the object should be a condition or constraint. + // The object should be a condition or constraint. const isCondition = this.#objectDiscovery.isCondition(node); if (isCondition) { const subResult = this.#validateCondition(node as Condition, depth + 1); @@ -116,27 +120,14 @@ export class Validator { return { isValid: false, error: { - message: "Each node should be a condition, constraint or sub rule.", - element: node, - }, - }; - } - - // Result is only valid on the root condition. - if (depth > 0 && "result" in condition) { - return { - isValid: false, - error: { - message: 'Nested conditions cannot have a property "result".', + message: "Each node should be a condition or a constraint.", element: node, }, }; } // If any part fails validation there is no point to continue. - if (!result.isValid) { - break; - } + if (!result.isValid) break; } return result; diff --git a/src/types/introspection.ts b/src/types/introspection.ts index a87fab1..79d12f4 100644 --- a/src/types/introspection.ts +++ b/src/types/introspection.ts @@ -1,9 +1,5 @@ -export interface CriteriaRange { - result: R; - options?: Record[]; -} +import { Constraint } from "./rule"; -export interface IntrospectionResult { - results: CriteriaRange[]; - default?: R; +export interface IntrospectionResult { + [key: string]: Omit[]; } diff --git a/src/types/rule.ts b/src/types/rule.ts index 8516d7f..da9cbd4 100644 --- a/src/types/rule.ts +++ b/src/types/rule.ts @@ -1,3 +1,6 @@ +export type WithRequired = Type & + Required>; + export type ConditionType = "any" | "all" | "none"; export type Operator = | "==" @@ -30,7 +33,6 @@ export interface Condition { any?: (Constraint | Condition)[]; all?: (Constraint | Condition)[]; none?: (Constraint | Condition)[]; - rule?: SubRule; result?: R; } @@ -38,8 +40,3 @@ export interface Rule { conditions: Condition | Condition[]; default?: R; } - -export interface SubRule { - conditions: Condition | Condition[]; - result?: R; -} diff --git a/test/builder.spec.ts b/test/builder.spec.ts index 3a0e937..c04d428 100644 --- a/test/builder.spec.ts +++ b/test/builder.spec.ts @@ -33,7 +33,13 @@ describe("RulePilot builder correctly", () => { 3 ) ) - .add(builder.condition("none", [], 5)) + .add( + builder.condition( + "none", + [builder.constraint("fieldC", "not in", [1, 2, 3])], + 5 + ) + ) .add( builder.condition("any", [builder.constraint("fieldA", "==", "value")]) ) @@ -54,7 +60,10 @@ describe("RulePilot builder correctly", () => { ], result: 3, }, - { none: [], result: 5 }, + { + none: [{ field: "fieldC", operator: "not in", value: [1, 2, 3] }], + result: 5, + }, { any: [{ field: "fieldA", operator: "==", value: "value" }], }, @@ -66,30 +75,32 @@ describe("RulePilot builder correctly", () => { it("Creates a complex ruleset with sub rules", () => { const builder = RulePilot.builder(); - const sub = builder.subRule(); - sub.result(33); - sub.add(sub.condition("all", [sub.constraint("fieldD", "==", "whoop")])); - const rule: Rule = builder .add( builder.condition( "all", [ - builder.condition( - "any", - [ - builder.constraint("fieldA", "==", "bar"), - builder.constraint("fieldB", ">=", 2), - ], - null, - sub - ), + builder.condition("any", [ + builder.constraint("fieldA", "==", "bar"), + builder.constraint("fieldB", ">=", 2), + builder.condition( + "all", + [builder.constraint("fieldD", "==", "whoop")], + 33 + ), + ]), builder.constraint("fieldC", "not in", [1, 2, 3]), ], 3 ) ) - .add(builder.condition("none", [], 5)) + .add( + builder.condition( + "none", + [builder.constraint("fieldE", "==", "hoop")], + 5 + ) + ) .add( builder.condition("any", [builder.constraint("fieldA", "==", "value")]) ) @@ -104,21 +115,20 @@ describe("RulePilot builder correctly", () => { any: [ { field: "fieldA", operator: "==", value: "bar" }, { field: "fieldB", operator: ">=", value: 2 }, + { + all: [{ field: "fieldD", operator: "==", value: "whoop" }], + result: 33, + }, ], - rule: { - conditions: [ - { - all: [{ field: "fieldD", operator: "==", value: "whoop" }], - }, - ], - result: 33, - }, }, { field: "fieldC", operator: "not in", value: [1, 2, 3] }, ], result: 3, }, - { none: [], result: 5 }, + { + none: [{ field: "fieldE", operator: "==", value: "hoop" }], + result: 5, + }, { any: [{ field: "fieldA", operator: "==", value: "value" }], }, @@ -135,14 +145,7 @@ describe("RulePilot builder correctly", () => { builder.condition( "all", [ - builder.condition( - "any", - [ - builder.constraint("fieldA", "==", "bar"), - builder.constraint("fieldB", ">=", 2), - ], - "Invalid!!" - ), + builder.condition("any", [], "Invalid!!"), builder.constraint("fieldC", "not in", [1, 2, 3]), ], 3 diff --git a/test/engine.spec.ts b/test/engine.spec.ts index fdfbc24..3b96f9b 100644 --- a/test/engine.spec.ts +++ b/test/engine.spec.ts @@ -4,6 +4,7 @@ import { valid3Json } from "./rulesets/valid3.json"; import { valid5Json } from "./rulesets/valid5.json"; import { invalid1Json } from "./rulesets/invalid1.json"; import { invalid2Json } from "./rulesets/invalid2.json"; +import { subRulesValid3Json } from "./rulesets/sub-rules-valid3.json"; import { subRulesValid1Json } from "./rulesets/sub-rules-valid1.json"; import { subRulesValid2Json } from "./rulesets/sub-rules-valid2.json"; @@ -257,13 +258,48 @@ describe("RulePilot engine correctly", () => { Category: "Islamic", }) ).toEqual(4); + + expect( + await RulePilot.evaluate(subRulesValid3Json, { + fieldA: "bar", + fieldC: 600, + }) + ).toEqual(3); + + expect( + await RulePilot.evaluate(subRulesValid3Json, { + fieldA: "bar", + fieldC: 600, + fieldD: "whoop", + }) + ).toEqual(33); + + expect( + await RulePilot.evaluate(subRulesValid3Json, { + fieldA: "bar", + fieldB: 2, + fieldD: "whoop", + }) + ).toEqual(33); + + expect( + await RulePilot.evaluate(subRulesValid3Json, { + fieldD: "whoop", + }) + ).toEqual(5); + + expect( + await RulePilot.evaluate(subRulesValid3Json, { + fieldA: "value", + }) + ).toEqual(5); }); it("Evaluates a rule with sub-rules where the parent condition has a nested rule", async () => { expect( await RulePilot.evaluate(subRulesValid2Json, { Category: "Demo", - Leverage: 600, + Leverage: 500, CountryIso: "GB", Monetization: "Real", }) diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index f75ac25..b1de7a8 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -1,4 +1,3 @@ -import { valid2Json } from "./rulesets/valid2.json"; import { valid3Json } from "./rulesets/valid3.json"; import { valid4Json } from "./rulesets/valid4.json"; import { valid6Json } from "./rulesets/valid6.json"; @@ -6,156 +5,494 @@ import { valid7Json } from "./rulesets/valid7.json"; import { valid8Json } from "./rulesets/valid8.json"; import { valid9Json } from "./rulesets/valid9.json"; import { invalid1Json } from "./rulesets/invalid1.json"; +import { subRulesValid2Json } from "./rulesets/sub-rules-valid2.json"; -import { RuleError, RulePilot, RuleTypeError } from "../src"; +import { Introspector } from "../src/services"; +import { RuleError, RulePilot, Constraint } from "../src"; describe("RulePilot introspector correctly", () => { it("Detects invalid rules", async () => { - expect(() => RulePilot.introspect(valid2Json)).toThrow(RuleTypeError); - expect(() => RulePilot.introspect(invalid1Json)).toThrow(RuleError); + const s = ["Leverage"]; + + expect(() => + RulePilot.introspect( + invalid1Json, + { field: "CountryIso", value: "GB" }, + s + ) + ).toThrow(RuleError); }); it("Introspects valid rules", async () => { - expect(RulePilot.introspect(valid3Json)).toEqual({ - results: [ - { - result: 3, - options: [ - { Leverage: { operator: ">=", value: 1000 } }, - { - CountryIso: ["GB", "FI"], - Leverage: { operator: "<", value: 200 }, - Monetization: "Real", - }, - ], - }, - { result: 4, options: [{ Category: "Islamic" }] }, - ], - default: 2, + let sub: string[] = ["Leverage"]; + let con: any = { field: "CountryIso", value: "GB" }; + expect(RulePilot.introspect(valid3Json, con, sub)).toEqual({ + 3: { + Leverage: [ + { value: 1000, operator: ">=" }, + { value: 200, operator: "<" }, + ], + }, + }); + + sub = ["Monetization"]; + con = { field: "Leverage", value: 199 }; + expect(RulePilot.introspect(valid3Json, con, sub)).toEqual({ + 3: { Monetization: [{ value: "Real", operator: "==" }] }, }); - expect(RulePilot.introspect(valid4Json)).toEqual({ - results: [ - { - result: 3, - options: [ - { Leverage: [1000, 500] }, - { - CountryIso: { operator: "contains", value: ["GB", "FI"] }, - Leverage: { operator: "<", value: 200 }, - Monetization: "Real", - Category: [{ operator: ">=", value: 1000 }, 22, 11, 12], - }, - { - CountryIso: { operator: "contains", value: ["GB", "FI"] }, - Leverage: { operator: "<", value: 200 }, - Monetization: "Real", - Category: [{ operator: ">=", value: 1000 }, 22], - HasStudentCard: true, - IsUnder18: true, - }, - ], - }, - { result: 4, options: [{ Category: "Islamic" }] }, - ], - default: 2, + sub = ["Monetization"]; + con = { field: "Leverage", value: 200 }; + expect(RulePilot.introspect(valid3Json, con, sub)).toEqual({}); + + sub = ["Leverage"]; + con = { field: "Category", value: 22 }; + expect(RulePilot.introspect(valid4Json, con, sub)).toEqual({ + 3: { + Leverage: [ + { value: 1000, operator: "==" }, + { value: 500, operator: "==" }, + { value: 200, operator: "<" }, + ], + }, }); - expect(RulePilot.introspect(valid6Json)).toEqual({ - results: [ - { - result: 3, - options: [ - { Leverage: [1000, 500] }, - { - CountryIso: { operator: "contains", value: ["GB", "FI"] }, - Leverage: { operator: "<", value: 200 }, - Monetization: "Real", - Category: [{ operator: ">=", value: 1000 }, 22, 11, 12], - }, - { - CountryIso: { operator: "contains", value: ["GB", "FI"] }, - Leverage: { operator: "<", value: 200 }, - Monetization: "Real", - Category: [{ operator: ">=", value: 1000 }, 22, 122], - IsUnder18: true, - }, - ], - }, - { result: 4, options: [{ Category: "Islamic" }] }, - ], - default: 2, + sub = ["Category"]; + con = { field: "Leverage", value: 199 }; + expect(RulePilot.introspect(valid4Json, con, sub)).toEqual({ + 3: { + Category: [ + { value: 1000, operator: ">=" }, + { value: 22, operator: "==" }, + { value: 11, operator: "==" }, + { value: 12, operator: "==" }, + ], + }, + 4: { Category: [{ value: "Islamic", operator: "==" }] }, }); - expect(RulePilot.introspect(valid7Json)).toEqual({ - results: [ - { - result: 3, - options: [ - { - Leverage: [ - { operator: "<", value: 1000 }, - { operator: ">=", value: 200 }, - ], - CountryIso: { operator: "not in", value: ["GB", "FI"] }, - Monetization: { operator: "!=", value: "Real" }, - }, - ], - }, - { - result: 4, - options: [{ Category: { operator: "!=", value: "Islamic" } }], - }, - ], + sub = ["IsUnder18"]; + con = { field: "Category", value: "Islamic" }; + expect(RulePilot.introspect(valid6Json, con, sub)).toEqual({}); + + sub = ["IsUnder18"]; + con = { field: "Category", value: 122 }; + expect(RulePilot.introspect(valid6Json, con, sub)).toEqual({}); + + sub = ["Monetization"]; + con = { field: "Category", value: 11 }; + expect(RulePilot.introspect(valid6Json, con, sub)).toEqual({}); + + sub = ["Leverage"]; + con = { field: "CountryIso", value: "DK" }; + expect(RulePilot.introspect(valid7Json, con, sub)).toEqual({ + 3: { + Leverage: [ + { value: 1000, operator: "<" }, + { value: 200, operator: ">=" }, + ], + }, }); - expect(RulePilot.introspect(valid8Json)).toEqual({ - results: [ - { - result: 3, - options: [ - { - Leverage: { operator: "<", value: 1000 }, - Type: "Demo", - OtherType: ["Live", "Fun"], - }, - { - Leverage: [ - { operator: "<", value: 1000 }, - { operator: ">=", value: 200 }, - ], - CountryIso: { operator: "not in", value: ["GB", "FI"] }, - Monetization: { operator: "!=", value: "Real" }, - }, - ], - }, - { - result: 4, - options: [{ Category: { operator: "!=", value: "Islamic" } }], - }, - ], + sub = ["Leverage"]; + con = { field: "CountryIso", value: "FI" }; + expect(RulePilot.introspect(valid7Json, con, sub)).toEqual({}); + + sub = ["OtherType"]; + con = { field: "Leverage", value: 999 }; + expect(RulePilot.introspect(valid8Json, con, sub)).toEqual({ + 3: { OtherType: [{ value: ["Live", "Fun"], operator: "in" }] }, }); - expect(RulePilot.introspect(valid9Json)).toEqual({ - results: [ - { - result: 5, - options: [ - { country: "SE" }, - { - country: ["GB", "FI"], - hasCoupon: true, - totalCheckoutPrice: { operator: ">=", value: 120 }, - }, - ], - }, - { - result: 10, - options: [ - { age: { operator: ">=", value: 18 }, hasStudentCard: true }, - ], - }, - ], + sub = ["totalCheckoutPrice"]; + con = { field: "country", value: "SE" }; + expect(RulePilot.introspect(valid9Json, con, sub)).toEqual({}); + + sub = ["Leverage", "Monetization"]; + con = { field: "Category", value: 22 }; + expect(RulePilot.introspect(subRulesValid2Json, con, sub)).toEqual({ + "3": { + Leverage: [ + { value: 1000, operator: "==" }, + { value: 500, operator: "==" }, + { value: "Demo", operator: "==" }, + ], + }, + "12": { Monetization: [{ value: "Real", operator: "==" }] }, + "13": { + Leverage: [ + { value: 1000, operator: "==" }, + { value: 500, operator: "==" }, + { value: "Demo", operator: "==" }, + ], + }, + "15": { + Leverage: [ + { value: 1000, operator: "==" }, + { value: 500, operator: "==" }, + { value: "Demo", operator: "==" }, + ], + }, }); }); + + it("Sanitizes results correctly", async () => { + let results: Constraint[] = [ + { field: "Lev", value: 200, operator: "<" }, + { field: "Lev", value: 200, operator: "==" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: 200, operator: "<=" }, + ]); + + results = [ + { field: "Lev", value: 200, operator: ">" }, + { field: "Lev", value: 200, operator: "==" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: 200, operator: ">=" }, + ]); + + results = [ + { field: "Lev", value: 200, operator: ">" }, + { field: "Lev", value: 300, operator: ">" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: 200, operator: ">" }, + ]); + + results = [ + { field: "Lev", value: 200, operator: "<" }, + { field: "Lev", value: 300, operator: "<" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: 300, operator: "<" }, + ]); + + results = [ + { field: "Lev", value: [200], operator: "in" }, + { field: "Lev", value: [200, 300], operator: "in" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: [200, 300], operator: "in" }, + ]); + + results = [ + { field: "Lev", value: [400], operator: "in" }, + { field: "Lev", value: [200, 300], operator: "in" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: [200, 300, 400], operator: "in" }, + ]); + + results = [ + { field: "Lev", value: [200], operator: "not in" }, + { field: "Lev", value: [200, 300], operator: "not in" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: [200, 300], operator: "not in" }, + ]); + + results = [ + { field: "Lev", value: [400], operator: "not in" }, + { field: "Lev", value: [200, 300], operator: "not in" }, + ]; + + expect(IntrospectorSpec.sanitizeFn(results)).toEqual([ + { field: "Lev", value: [200, 300, 400], operator: "not in" }, + ]); + }); + + it("Tests new introspection candidates against existing ones", async () => { + const input = { field: "Category", value: 30 }; + + let candidates: Constraint[] = [ + { field: "Lev", value: 200, operator: "<" }, + { field: "Lev", value: 20, operator: ">" }, + ]; + + let item: Constraint = { field: "Lev", value: 200, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 201, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 19, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 199, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 21, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 30, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 20, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 300, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 3, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 21, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 3, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 210, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 200, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 199, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 210, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + ////////////////////////////////////////////////////////////////////// + + candidates = [ + { field: "Lev", value: 200, operator: "<=" }, + { field: "Lev", value: 20, operator: ">=" }, + ]; + + item = { field: "Lev", value: 200, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 201, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 19, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 199, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 21, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 30, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 300, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 3, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 20, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 21, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 3, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 210, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 200, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 199, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 210, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + ////////////////////////////////////////////////////////////////////// + + candidates = [{ field: "Lev", value: 200, operator: "==" }]; + + item = { field: "Lev", value: 210, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 200, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 200, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 201, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 190, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 200, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 200, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 99, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [200, 300], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [300], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: [200, 300], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: [300], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + // + + candidates = [{ field: "Lev", value: [10, 20, 30], operator: "in" }]; + + item = { field: "Lev", value: 210, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 20, operator: "!=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [55, 65, 75], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: [10, 65, 75], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [55, 65, 75], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 65, 75], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 20, 75], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 20, 30], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + // + + item = { field: "Lev", value: 30, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 30, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 30, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 40, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 40, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + // + + item = { field: "Lev", value: 10, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 10, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 10, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 5, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 5, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + // + + candidates = [{ field: "Lev", value: [10, 20, 30], operator: "not in" }]; + + item = { field: "Lev", value: 210, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 20, operator: "==" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + item = { field: "Lev", value: 20, operator: "!=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [55, 65, 75], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10], operator: "not in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 65, 75], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 20, 75], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: [10, 20, 30], operator: "in" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(false); + + // + + item = { field: "Lev", value: 30, operator: ">" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 30, operator: ">=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 30, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 40, operator: "<" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + + item = { field: "Lev", value: 40, operator: "<=" }; + expect(IntrospectorSpec.testFn(candidates, input, item)).toEqual(true); + }); }); + +class IntrospectorSpec extends Introspector { + constructor() { + super(); + } + + static testFn( + candidates: Constraint[], + input: Omit, + item: Constraint + ): any { + return new IntrospectorSpec().test(candidates, input, item, 0); + } + + static sanitizeFn(constraints: Constraint[]): any { + return new IntrospectorSpec().sanitize(constraints, 0); + } +} diff --git a/test/rulesets/invalid1.json.ts b/test/rulesets/invalid1.json.ts index 15ef473..5c0e248 100644 --- a/test/rulesets/invalid1.json.ts +++ b/test/rulesets/invalid1.json.ts @@ -1,4 +1,4 @@ -import { Rule } from "../../src"; +import { Rule, Condition } from "../../src"; export const invalid1Json: Rule = { conditions: [ @@ -21,9 +21,10 @@ export const invalid1Json: Rule = { operator: "==", value: "Real", }, + { + foo: "bar", + } as Condition, ], - // Result property is only allowed on the top level conditions - result: "This is invalid!!!!!!!", }, { all: [ diff --git a/test/rulesets/sub-rules-valid1.json.ts b/test/rulesets/sub-rules-valid1.json.ts index a54d332..fc97f60 100644 --- a/test/rulesets/sub-rules-valid1.json.ts +++ b/test/rulesets/sub-rules-valid1.json.ts @@ -15,65 +15,53 @@ export const subRulesValid1Json: Rule = { value: 500, }, { - rule: { - conditions: [ - { - all: [ - { - field: "CountryIso", - operator: "in", - value: ["GB", "FI"], - }, - { - field: "Leverage", - operator: ">", - value: 500, - }, - { - field: "Monetization", - operator: "==", - value: "Real", - }, - { - rule: { - conditions: [ - { - any: [ - { - field: "Category", - operator: ">=", - value: 1000, - }, - { - field: "Category", - operator: "==", - value: 22, - }, - { - any: [ - { - field: "Category", - operator: "==", - value: 900, - }, - { - field: "Category", - operator: "==", - value: 910, - }, - ], - }, - ], - }, - ], - result: 13, + all: [ + { + field: "CountryIso", + operator: "in", + value: ["GB", "FI"], + }, + { + field: "Leverage", + operator: ">", + value: 500, + }, + { + field: "Monetization", + operator: "==", + value: "Real", + }, + { + any: [ + { + field: "Category", + operator: ">=", + value: 1000, + }, + { + field: "Category", + operator: "==", + value: 22, + }, + { + any: [ + { + field: "Category", + operator: "==", + value: 900, }, - }, - ], - }, - ], - result: 12, - }, + { + field: "Category", + operator: "==", + value: 910, + }, + ], + }, + ], + result: 13, + }, + ], + result: 12, }, ], result: 3, diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index 83335ae..d0163ed 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -1,11 +1,55 @@ import { Rule } from "../../src"; -// Go through each root condition -// if any of the nodes is a sub-rule -// -> +// Test against Category 900 +// -> Leverage 1000, 500 +// -> Monetization Demo export const subRulesValid2Json: Rule = { conditions: [ + { + none: [ + { + field: "Category", + operator: "==", + value: 900, + }, + { + field: "Leverage", + operator: "!=", + value: 500, + }, + { + any: [ + { + field: "Leverage", + operator: "==", + value: 1000, + }, + { + field: "Leverage", + operator: "==", + value: 500, + }, + { + any: [ + { + field: "Leverage", + operator: "==", + value: 2000, + }, + { + field: "Leverage", + operator: "==", + value: 1500, + }, + ], + result: 100, + }, + ], + }, + ], + result: 50, + }, { any: [ { @@ -21,107 +65,89 @@ export const subRulesValid2Json: Rule = { { all: [ { - field: "Category", + field: "Leverage", operator: "==", value: "Demo", }, { - rule: { - conditions: [ - { - any: [ - { - field: "Category", - operator: ">=", - value: 1000, - }, - { - field: "Category", - operator: "==", - value: 22, - }, - { - any: [ - { - field: "Category", - operator: "==", - value: 900, - }, - { - field: "Category", - operator: "==", - value: 910, - }, - ], - }, - ], - }, - ], - result: 15, - }, + any: [ + { + field: "Category", + operator: ">=", + value: 1000, + }, + { + field: "Category", + operator: "==", + value: 22, + }, + { + any: [ + { + field: "Category", + operator: "==", + value: 900, + }, + { + field: "Category", + operator: "==", + value: 910, + }, + ], + }, + ], + result: 15, }, ], }, { - rule: { - conditions: [ - { - all: [ - { - field: "CountryIso", - operator: "in", - value: ["GB", "FI"], - }, - { - field: "Leverage", - operator: ">", - value: 500, - }, - { - field: "Monetization", - operator: "==", - value: "Real", - }, - { - rule: { - conditions: [ - { - any: [ - { - field: "Category", - operator: ">=", - value: 1000, - }, - { - field: "Category", - operator: "==", - value: 22, - }, - { - any: [ - { - field: "Category", - operator: "==", - value: 900, - }, - { - field: "Category", - operator: "==", - value: 910, - }, - ], - }, - ], - }, - ], - result: 13, + all: [ + { + field: "CountryIso", + operator: "in", + value: ["GB", "FI"], + }, + { + field: "Leverage", + operator: ">", + value: 400, + }, + { + field: "Monetization", + operator: "==", + value: "Real", + }, + { + any: [ + { + field: "Category", + operator: ">=", + value: 1000, + }, + { + field: "Category", + operator: "==", + value: 22, + }, + { + any: [ + { + field: "Category", + operator: "==", + value: 900, + }, + { + field: "Category", + operator: "==", + value: 910, }, - }, - ], - }, - ], - result: 12, - }, + ], + }, + ], + result: 13, + }, + ], + result: 12, }, ], result: 3, diff --git a/test/rulesets/sub-rules-valid3.json.ts b/test/rulesets/sub-rules-valid3.json.ts new file mode 100644 index 0000000..9685300 --- /dev/null +++ b/test/rulesets/sub-rules-valid3.json.ts @@ -0,0 +1,54 @@ +import { Rule } from "../../src"; + +export const subRulesValid3Json: Rule = { + conditions: [ + { + all: [ + { + any: [ + { + field: "fieldA", + operator: "==", + value: "bar", + }, + { + field: "fieldB", + operator: ">=", + value: 2, + }, + { + all: [ + { + field: "fieldD", + operator: "==", + value: "whoop", + }, + ], + result: 33, + }, + ], + }, + { + field: "fieldC", + operator: "not in", + value: [1, 2, 3], + }, + ], + result: 3, + }, + { + none: [{ field: "fieldE", operator: "==", value: "hoop" }], + result: 5, + }, + { + any: [ + { + field: "fieldA", + operator: "==", + value: "value", + }, + ], + }, + ], + default: 2, +}; diff --git a/test/validator.spec.ts b/test/validator.spec.ts index bdc83b8..dcb725d 100644 --- a/test/validator.spec.ts +++ b/test/validator.spec.ts @@ -95,7 +95,7 @@ describe("RulePilot validator correctly", () => { expect(validation.isValid).toEqual(false); expect(validation.error.message).toEqual( - 'Nested conditions cannot have a property "result".' + "Each node should be a condition or a constraint." ); });