From db761c0ac5aab033ab1aa64481dfaf2e27037105 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Sat, 12 Oct 2024 18:51:33 +0200 Subject: [PATCH 01/18] feat(core): Simplify sub rules format --- README.md | 28 ++--- package.json | 2 +- src/builder/builder.ts | 13 +- src/builder/index.ts | 1 - src/builder/sub-rule-builder.ts | 75 ----------- src/services/evaluator.ts | 103 +++++++-------- src/services/introspector.ts | 56 +++++---- src/services/object-discovery.ts | 35 +++--- src/services/validator.ts | 39 +++--- src/types/rule.ts | 9 +- test/builder.spec.ts | 69 +++++----- test/engine.spec.ts | 36 ++++++ test/rulesets/invalid1.json.ts | 7 +- test/rulesets/sub-rules-valid1.json.ts | 104 +++++++-------- test/rulesets/sub-rules-valid2.json.ts | 168 +++++++++++-------------- test/rulesets/sub-rules-valid3.json.ts | 54 ++++++++ test/validator.spec.ts | 2 +- 17 files changed, 382 insertions(+), 419 deletions(-) delete mode 100644 src/builder/sub-rule-builder.ts create mode 100644 test/rulesets/sub-rules-valid3.json.ts diff --git a/README.md b/README.md index 5f1294e..981d6a3 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 } diff --git a/package.json b/package.json index 2cf4c49..374aec7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "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/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..a618fe3 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -1,8 +1,8 @@ import { Rule, - SubRule, Condition, Constraint, + WithRequired, CriteriaRange, ConditionType, IntrospectionResult, @@ -21,7 +21,7 @@ interface IntrospectionStep { interface SubRuleResult { parent: Condition; - subRule: SubRule; + subRule: any; } /** @@ -50,11 +50,14 @@ export class Introspector { let subRuleResults: SubRuleResult[] = []; for (const condition of this.#asArray(rule.conditions)) { subRuleResults = subRuleResults.concat( - this.#findSubRules(condition, condition) + this.#findNestedResultConditions(condition, condition) ); } + console.log(subRuleResults); + let results = this.#introspectRule(rule); + console.log(JSON.stringify(results)); // Construct a new rule from each sub-rule result for (const subRuleResult of subRuleResults) { @@ -98,7 +101,7 @@ export class Introspector { #introspectRule(rule: Rule): CriteriaRange[] { // Initialize a clean steps array each time we introspect this.#steps = []; - const conditions = this.#asArray(rule.conditions); + const conditions = this.#stripAllSubRules(this.#asArray(rule.conditions)); // Then we map each result to the condition that produces // it to create a map of results to conditions @@ -128,7 +131,7 @@ export class Introspector { * @param root The root condition which holds the condition to search. * @param results The results array to populate. */ - #findSubRules( + #findNestedResultConditions( condition: Condition, root: Condition, results: SubRuleResult[] = [] @@ -138,24 +141,26 @@ export class Introspector { // Iterate each node in the condition for (const node of condition[type]) { - if (this.#objectDiscovery.isSubRule(node)) { + if (this.#objectDiscovery.isConditionWithResult(node)) { results.push({ - parent: this.#removeSubRule(node.rule, root), + parent: this.#removeSubRule(node, root), subRule: { - conditions: this.#stripAllSubRules(node.rule.conditions), - result: node.rule.result, + conditions: this.#stripAllSubRules(node), + result: node.result, }, }); // Recursively find sub-rules within the sub-rule - for (const condition of this.#asArray(node.rule.conditions)) { - results = this.#findSubRules(condition, root, results); + for (const condition of this.#asArray(node)) { + results = this.#findNestedResultConditions(condition, root, results); } + + return results; } // Recursively find sub-rules within the condition if (this.#objectDiscovery.isCondition(node)) { - results = this.#findSubRules(node, root, results); + results = this.#findNestedResultConditions(node, root, results); } } @@ -167,7 +172,10 @@ export class Introspector { * @param needle The sub-rule to remove. * @param haystack The condition to search in and remove the sub-rule from. */ - #removeSubRule(needle: SubRule, haystack: Condition): Condition { + #removeSubRule( + needle: WithRequired, + haystack: Condition + ): Condition { // Clone the root condition so that we can modify it const clone = JSON.parse(JSON.stringify(haystack)); @@ -185,14 +193,14 @@ export class Introspector { } // If the node is a sub-rule - if (this.#objectDiscovery.isSubRule(node)) { - if (!this.existsIn(needle, node.rule.conditions)) { + if (this.#objectDiscovery.isConditionWithResult(node)) { + if (!this.existsIn(needle, node)) { clone[type].splice(i, 1); continue; } // Otherwise, recurse into the sub-rule - const conditions = this.#asArray(node.rule.conditions); + const conditions = this.#asArray(node); for (let j = 0; j < conditions.length; j++) { clone[type][i].rule.conditions[j] = this.#removeSubRule( needle, @@ -212,7 +220,7 @@ export class Introspector { * @param found A flag to indicate if the sub-rule has been found. */ existsIn( - needle: SubRule, + needle: WithRequired, haystack: unknown, found: boolean = false ): boolean { @@ -221,11 +229,11 @@ export class Introspector { // 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)) { + if (this.#objectDiscovery.isConditionWithResult(node)) { // Check if it is the sub-rule we are looking for - if (JSON.stringify(needle) === JSON.stringify(node.rule)) return true; + if (JSON.stringify(needle) === JSON.stringify(node)) return true; // Otherwise, recurse into the sub-rule - found = this.existsIn(needle, node.rule.conditions, found); + found = this.existsIn(needle, node, found); } // If the node is a condition, recurse @@ -263,7 +271,8 @@ export class Introspector { } // If the node is a sub-rule, remove it - if (this.#objectDiscovery.isSubRule(node)) clone[i][type].splice(j, 1); + if (this.#objectDiscovery.isConditionWithResult(node)) + clone[i][type].splice(j, 1); } } @@ -287,8 +296,9 @@ export class Introspector { const type = this.#objectDiscovery.conditionType(condition); for (const node of condition[type]) { - if (this.#objectDiscovery.isSubRule(node)) { - result = this.#flatten(node.rule.conditions, result); + if (this.#objectDiscovery.isConditionWithResult(node)) { + delete node.result; + result = this.#flatten(node, result); continue; } 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/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/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..1c1e08b 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,6 +258,41 @@ 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 () => { 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..99f2dee 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -1,9 +1,5 @@ import { Rule } from "../../src"; -// Go through each root condition -// if any of the nodes is a sub-rule -// -> - export const subRulesValid2Json: Rule = { conditions: [ { @@ -26,102 +22,84 @@ export const subRulesValid2Json: Rule = { 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: 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-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." ); }); From e2cbedff9011d170c8aca24cccd650bffcd0ac98 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Sat, 12 Oct 2024 18:54:42 +0200 Subject: [PATCH 02/18] feat(core): Simplify sub rules format --- README.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 981d6a3..3f3910e 100644 --- a/README.md +++ b/README.md @@ -726,18 +726,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( @@ -746,11 +734,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( From 21ff392e514516c31dddfffe255ef67d72f7311d Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 6 Nov 2024 10:27:28 +0100 Subject: [PATCH 03/18] feat(core):Work on introspection v2 --- .eslintrc.js | 2 +- prettier.json | 2 +- src/services/introspector.ts | 239 +++++++++++++++++++++++-- src/services/rule-pilot.ts | 22 ++- test/introspector.spec.ts | 60 +++++++ test/rulesets/sub-rules-valid2.json.ts | 35 +++- 6 files changed, 342 insertions(+), 18 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 6f3602e..fe1c514 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,7 +32,7 @@ module.exports = { }, }, "newlines-between": "always", - "max-line-length": 80, + "max-line-length": 120, }, ], "perfectionist/sort-named-imports": [ diff --git a/prettier.json b/prettier.json index 72f7c03..4b9a2d9 100644 --- a/prettier.json +++ b/prettier.json @@ -1,5 +1,5 @@ { - "printWidth": 80, + "printWidth": 120, "singleQuote": true, "trailingComma": "all" } diff --git a/src/services/introspector.ts b/src/services/introspector.ts index a618fe3..8648bd3 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -33,20 +33,229 @@ export class Introspector { #objectDiscovery: ObjectDiscovery = new ObjectDiscovery(); #steps: IntrospectionStep[]; + intrNew( + rule: Rule, + constraint: Constraint, + subjects: string[] + ): IntrospectionResult { + // The ruleset needs to be granular for this operation to work + if (!this.#objectDiscovery.isGranular(rule)) { + throw new RuleTypeError("Introspection requires granular rules."); + } + + // We care about all the possible values for the subjects which will satisfy + // the rule if the rule is tested against the constraint provided. + + // 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] + ); + } + + // We extract each root condition from the rule and evaluate the condition against the constraint. + + // If the condition passes the check, we know we have subjects in the condition which we should return + + // If the condition fails the check but contains the constraint, it means we want to + + // this condition against the constraint we have. If the condition passes the constraint + + return null; + } + + /** + * 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. + */ + #reverseNoneToAll( + condition: Condition, + shouldFlip: boolean = false + ): Condition { + const type = this.#objectDiscovery.conditionType(condition); + if ("none" === type) shouldFlip = !shouldFlip; + + // Iterate each node in the condition + for (let i = 0; i < condition[type].length; i++) { + let node = condition[type][i]; + + // 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); + } + + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(node)) { + node = this.#reverseNoneToAll(node as Condition, shouldFlip); + } + } + + if (shouldFlip) { + condition["all"] = condition[type]; + delete condition[type]; + } + + return this.#stripNullProps(condition); + } + + /** + * 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. + */ + #removeIrrelevantConstraints( + condition: Condition, + toKeep: string[] + ): Condition { + const type = this.#objectDiscovery.conditionType(condition); + + // Iterate each node in the condition + for (let i = 0; i < condition[type].length; i++) { + let node = condition[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)) { + node = this.#removeIrrelevantConstraints(node as Condition, toKeep); + } + } + + return this.#stripNullProps(condition); + } + + /** + * 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 c; + } + + /** + * Removes all null properties from an object. + * @param obj The object to remove null properties from. + * @param defaults The default values to remove. + */ + #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; + } + /** * 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. + * @param constraint The constraint to introspect against. + * @param subjects The subjects to introspect for. * @throws RuleTypeError if the rule is not granular */ - introspect(rule: Rule): IntrospectionResult { + introspect( + rule: Rule, + constraint: Constraint, + subjects: string[] + ): IntrospectionResult { // The ruleset needs to be granular for this operation to work if (!this.#objectDiscovery.isGranular(rule)) { throw new RuleTypeError("Introspection requires granular rules."); } + this.intrNew(rule, constraint, subjects); + // Find any conditions which contain sub-rules + // We extract each sub-rule along with the parent rule that needs to pass in order + // for the subrule to be applicable. + // todo this is leaving the original sub rule in the parent rule let subRuleResults: SubRuleResult[] = []; for (const condition of this.#asArray(rule.conditions)) { subRuleResults = subRuleResults.concat( @@ -54,10 +263,10 @@ export class Introspector { ); } - console.log(subRuleResults); + console.log(JSON.stringify(subRuleResults)); - let results = this.#introspectRule(rule); - console.log(JSON.stringify(results)); + let results = this.#introspectRule(rule, constraint, subjects); + //console.log(JSON.stringify(results)); // Construct a new rule from each sub-rule result for (const subRuleResult of subRuleResults) { @@ -77,12 +286,16 @@ export class Introspector { // ); results = results.concat( - this.#introspectRule({ - conditions: { - ...res, - result: subRuleResult.subRule.result, + this.#introspectRule( + { + conditions: { + ...res, + result: subRuleResult.subRule.result, + }, }, - }) + constraint, + subjects + ) ); } @@ -97,8 +310,14 @@ export class Introspector { /** * Runs the introspection process on a rule to determine the possible range of input criteria * @param rule The rule to introspect. + * @param constraint The constraint to introspect against. + * @param subjects The subjects to introspect for. */ - #introspectRule(rule: Rule): CriteriaRange[] { + #introspectRule( + rule: Rule, + constraint: Constraint, + subjects: string[] + ): CriteriaRange[] { // Initialize a clean steps array each time we introspect this.#steps = []; const conditions = this.#stripAllSubRules(this.#asArray(rule.conditions)); diff --git a/src/services/rule-pilot.ts b/src/services/rule-pilot.ts index 1948ad4..39cde93 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: Constraint, + 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: Constraint, + subjects: string[] + ): IntrospectionResult { + return RulePilot.#rulePilot.introspect(rule, constraint, subjects); } /** diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index f75ac25..e765b0e 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -6,6 +6,7 @@ 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"; @@ -158,4 +159,63 @@ describe("RulePilot introspector correctly", () => { ], }); }); + + expect(RulePilot.introspect(subRulesValid2Json)).toEqual({ + results: [ + { + result: 3, + options: [{ Leverage: [1000, 500] }, { Category: "Demo" }], + }, + { + result: 4, + options: [{ Category: "Islamic" }], + }, + { + result: 15, + options: [ + { + Leverage: [{ operator: ">", value: 500 }, 1000, 500], // the gt 500 is wrong here + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], + CountryIso: ["GB", "FI"], // incorrect (should not exist) + Monetization: "Real", // incorrect (should not exist) + }, + { + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], + Leverage: [], + }, + ], + }, + { + result: 12, + options: [ + { + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], + CountryIso: ["GB", "FI"], + Leverage: [{ operator: ">", value: 500 }, 1000, 500], + Monetization: "Real", + }, + { + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], + Leverage: [], + }, + ], + }, + { + result: 13, + options: [ + { + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], + CountryIso: ["GB", "FI"], + Leverage: [{ operator: ">", value: 500 }, 1000, 500], + Monetization: "Real", + }, + { + Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], + Leverage: [], + }, + ], + }, + ], + default: 2, + }); }); diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index 99f2dee..a1cb5e0 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -1,7 +1,40 @@ import { Rule } from "../../src"; +// 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, + }, + ], + }, + ], + result: 50, + }, { any: [ { @@ -17,7 +50,7 @@ export const subRulesValid2Json: Rule = { { all: [ { - field: "Category", + field: "Monetization", operator: "==", value: "Demo", }, From e88c672bbe0691a426de0486d47db26bffaad0b0 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 6 Nov 2024 17:37:03 +0100 Subject: [PATCH 04/18] feat(core):Work on introspection v2 --- src/services/introspector.ts | 136 +++++++++++++++++++++++-- test/rulesets/sub-rules-valid2.json.ts | 5 +- 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 8648bd3..6e1dd01 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -59,6 +59,24 @@ export class Introspector { ); } + // We then need to extract all sub-rules from the main rule + let subRuleResults: SubRuleResult[] = []; + for (const condition of rule.conditions) { + subRuleResults = subRuleResults.concat(this.#extractSubRules(condition)); + } + + // We then create a new version of the rule without any of the sub-rules + for (let i = 0; i < rule.conditions.length; i++) { + rule.conditions[i] = this.#removeAllSubRules(rule.conditions[i]); + } + + console.log("boom", JSON.stringify(subRuleResults)); + console.log("bam", JSON.stringify(rule)); + + // Any further introspection will be done on the new rule and the sub-rules extracted + // 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. + // We extract each root condition from the rule and evaluate the condition against the constraint. // If the condition passes the check, we know we have subjects in the condition which we should return @@ -231,6 +249,110 @@ export class Introspector { : 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)) { + results = this.#extractSubRules(node, results, root); + } + } + + return results; + } + + /** + * 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. + */ + #removeNode(node: Record, 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 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); + + // 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 condition, recurse + if (this.#objectDiscovery.isCondition(clone[type][i])) { + clone[type][i] = this.#removeNode(node, clone[type][i]); + } + } + + return this.#stripNullProps(clone); + } + + /** + * Removes all subrules from the provided condition. + * @param haystack The condition to search in and remove all sub-rules from. + */ + #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 condition, recurse + if (this.#objectDiscovery.isCondition(clone[type][i])) { + clone[type][i] = this.#removeAllSubRules(clone[type][i]); + } + } + + return this.#stripNullProps(clone); + } + /** * Given a rule, checks the constraints and conditions to determine * the possible range of input criteria which would be satisfied by the rule. @@ -259,12 +381,10 @@ export class Introspector { let subRuleResults: SubRuleResult[] = []; for (const condition of this.#asArray(rule.conditions)) { subRuleResults = subRuleResults.concat( - this.#findNestedResultConditions(condition, condition) + this.#findSubRules(condition, condition) ); } - console.log(JSON.stringify(subRuleResults)); - let results = this.#introspectRule(rule, constraint, subjects); //console.log(JSON.stringify(results)); @@ -350,7 +470,7 @@ export class Introspector { * @param root The root condition which holds the condition to search. * @param results The results array to populate. */ - #findNestedResultConditions( + #findSubRules( condition: Condition, root: Condition, results: SubRuleResult[] = [] @@ -371,7 +491,7 @@ export class Introspector { // Recursively find sub-rules within the sub-rule for (const condition of this.#asArray(node)) { - results = this.#findNestedResultConditions(condition, root, results); + results = this.#findSubRules(condition, root, results); } return results; @@ -379,7 +499,7 @@ export class Introspector { // Recursively find sub-rules within the condition if (this.#objectDiscovery.isCondition(node)) { - results = this.#findNestedResultConditions(node, root, results); + results = this.#findSubRules(node, root, results); } } @@ -601,9 +721,7 @@ export class Introspector { const options = new Map>(); for (const node of condition[type]) { - if (!this.#objectDiscovery.isConstraint(node)) { - continue; - } + if (!this.#objectDiscovery.isConstraint(node)) continue; const constraint = node as Constraint; diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index a1cb5e0..45cddf6 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -7,7 +7,7 @@ import { Rule } from "../../src"; export const subRulesValid2Json: Rule = { conditions: [ { - none: [ + all: [ { field: "Category", operator: "==", @@ -31,6 +31,7 @@ export const subRulesValid2Json: Rule = { value: 500, }, ], + result: 100, }, ], result: 50, @@ -50,7 +51,7 @@ export const subRulesValid2Json: Rule = { { all: [ { - field: "Monetization", + field: "Category", operator: "==", value: "Demo", }, From 48210469ca78aa4731833a0601eb76f7861edfe9 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Thu, 7 Nov 2024 12:58:16 +0100 Subject: [PATCH 05/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 54 ++++++++++++++++++++++++-- test/rulesets/sub-rules-valid2.json.ts | 4 +- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 6e1dd01..985b10c 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -8,6 +8,7 @@ import { IntrospectionResult, } from "../types"; import { Logger } from "./logger"; +import { Evaluator } from "./evaluator"; import { RuleTypeError } from "../errors"; import { ObjectDiscovery } from "./object-discovery"; @@ -30,6 +31,7 @@ interface SubRuleResult { * produced by the criteria. */ export class Introspector { + #evaluator: Evaluator = new Evaluator(); #objectDiscovery: ObjectDiscovery = new ObjectDiscovery(); #steps: IntrospectionStep[]; @@ -65,19 +67,43 @@ export class Introspector { subRuleResults = subRuleResults.concat(this.#extractSubRules(condition)); } + // console.log("boom", JSON.stringify(subRuleResults)); + // We then create a new version of the rule without any of the sub-rules + let ruleConditions: Condition[] = []; for (let i = 0; i < rule.conditions.length; i++) { - rule.conditions[i] = this.#removeAllSubRules(rule.conditions[i]); + ruleConditions.push(this.#removeAllSubRules(rule.conditions[i])); } - console.log("boom", JSON.stringify(subRuleResults)); - console.log("bam", JSON.stringify(rule)); + // console.log("bam", JSON.stringify(ruleConditions)); // Any further introspection will be done on the new rule and the sub-rules extracted // 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. - // We extract each root condition from the rule and evaluate the condition against the constraint. + // If each rule has a constraint in it which matches the type of one of the constraint we have, then + // we need to evaluate the rule against the constraint and only consider the rule if it passes. + + // For the new rule + ruleConditions = ruleConditions.filter((condition) => { + return this.#hasConstraintField(constraint.field, condition) + ? this.#evaluator.evaluate( + { conditions: [condition] }, + { [constraint.field]: constraint.value } + ) + : true; + }); + + // For the sub-rules + // When evaluating the sub-rule, we need to first make sure the parent condition passes + // before we can evaluate the sub-rule, otherwise we can discard the sub-rule entirely. + subRuleResults = subRuleResults.filter((rule) => {}); + + console.log("tam", JSON.stringify(ruleConditions)); + + // At this point the task has been simplified to it's maximum capacity. We can now introspect each + // rule and sub-rule remaining to determine the possible range of input criteria which would satisfy + // the rule. These criteria will match the subjects provided to this function. // If the condition passes the check, we know we have subjects in the condition which we should return @@ -353,6 +379,26 @@ export class Introspector { return this.#stripNullProps(clone); } + #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; + } + /** * Given a rule, checks the constraints and conditions to determine * the possible range of input criteria which would be satisfied by the rule. diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index 45cddf6..f01df92 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -7,7 +7,7 @@ import { Rule } from "../../src"; export const subRulesValid2Json: Rule = { conditions: [ { - all: [ + none: [ { field: "Category", operator: "==", @@ -51,7 +51,7 @@ export const subRulesValid2Json: Rule = { { all: [ { - field: "Category", + field: "Leverage", operator: "==", value: "Demo", }, From 71d1c75d26fae9afb193bcad61d79f88423adbb0 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Mon, 11 Nov 2024 17:20:02 +0100 Subject: [PATCH 06/18] feat(core): Work on introspection v2 --- .eslintrc.js | 2 +- prettier.json | 2 +- src/services/introspector.ts | 1316 ++++++++++++++++------------------ src/services/rule-pilot.ts | 18 +- src/types/index.ts | 1 - src/types/introspection.ts | 9 - 6 files changed, 622 insertions(+), 726 deletions(-) delete mode 100644 src/types/introspection.ts diff --git a/.eslintrc.js b/.eslintrc.js index fe1c514..f7e11b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,7 +32,7 @@ module.exports = { }, }, "newlines-between": "always", - "max-line-length": 120, + "max-line-length": 140, }, ], "perfectionist/sort-named-imports": [ diff --git a/prettier.json b/prettier.json index 4b9a2d9..ed273d2 100644 --- a/prettier.json +++ b/prettier.json @@ -1,5 +1,5 @@ { - "printWidth": 120, + "printWidth": 140, "singleQuote": true, "trailingComma": "all" } diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 985b10c..49539a4 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -1,28 +1,16 @@ -import { - Rule, - Condition, - Constraint, - WithRequired, - CriteriaRange, - ConditionType, - IntrospectionResult, -} from "../types"; import { Logger } from "./logger"; -import { Evaluator } from "./evaluator"; -import { RuleTypeError } from "../errors"; import { ObjectDiscovery } from "./object-discovery"; +import { Rule, Condition, Constraint } 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: any; +interface ConditionResult { + values?: Map; + stop: boolean; + void: boolean; } /** @@ -31,20 +19,13 @@ interface SubRuleResult { * produced by the criteria. */ export class Introspector { - #evaluator: Evaluator = new Evaluator(); #objectDiscovery: ObjectDiscovery = new ObjectDiscovery(); - #steps: IntrospectionStep[]; - intrNew( + introspect( rule: Rule, - constraint: Constraint, + constraint: Omit, subjects: string[] - ): IntrospectionResult { - // The ruleset needs to be granular for this operation to work - if (!this.#objectDiscovery.isGranular(rule)) { - throw new RuleTypeError("Introspection requires granular rules."); - } - + ): Record[]> { // We care about all the possible values for the subjects which will satisfy // the rule if the rule is tested against the constraint provided. @@ -62,54 +43,51 @@ export class Introspector { } // We then need to extract all sub-rules from the main rule - let subRuleResults: SubRuleResult[] = []; + let subRules: SubRuleResult[] = []; for (const condition of rule.conditions) { - subRuleResults = subRuleResults.concat(this.#extractSubRules(condition)); + subRules = subRules.concat(this.#extractSubRules(condition)); } - // console.log("boom", JSON.stringify(subRuleResults)); - // We then create a new version of the rule without any of the sub-rules - let ruleConditions: Condition[] = []; + const conditions: Condition[] = []; for (let i = 0; i < rule.conditions.length; i++) { - ruleConditions.push(this.#removeAllSubRules(rule.conditions[i])); + conditions.push(this.#removeAllSubRules(rule.conditions[i])); } - // console.log("bam", JSON.stringify(ruleConditions)); + subRules.forEach((rule) => { + conditions.push( + rule.parent ? { all: [rule.parent, rule.subRule] } : rule.subRule + ); + }); + + // todo remove + console.log("subRules", JSON.stringify(subRules)); + console.log("conditions", JSON.stringify(conditions)); - // Any further introspection will be done on the new rule and the sub-rules extracted // 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. - // If each rule has a constraint in it which matches the type of one of the constraint we have, then - // we need to evaluate the rule against the constraint and only consider the rule if it passes. + const results = {}; - // For the new rule - ruleConditions = ruleConditions.filter((condition) => { - return this.#hasConstraintField(constraint.field, condition) - ? this.#evaluator.evaluate( - { conditions: [condition] }, - { [constraint.field]: constraint.value } - ) - : true; - }); - - // For the sub-rules - // When evaluating the sub-rule, we need to first make sure the parent condition passes - // before we can evaluate the sub-rule, otherwise we can discard the sub-rule entirely. - subRuleResults = subRuleResults.filter((rule) => {}); - - console.log("tam", JSON.stringify(ruleConditions)); + // We introspect the conditions to determine the possible values for the subjects + for (const condition of conditions) { + const { values } = this.#introspectConditions(condition, constraint); + if (!values) continue; - // At this point the task has been simplified to it's maximum capacity. We can now introspect each - // rule and sub-rule remaining to determine the possible range of input criteria which would satisfy - // the rule. These criteria will match the subjects provided to this function. + // Merge the results maintaining the uniqueness of the values + for (const [field, constraints] of values.entries()) { + if (!subjects.includes(field)) continue; - // If the condition passes the check, we know we have subjects in the condition which we should return + const set = new Set([...(results[field] ?? [])]); + for (const constraint of constraints) { + set.add({ value: constraint.value, operator: constraint.operator }); + } - // If the condition fails the check but contains the constraint, it means we want to + results[field] = Array.from(set); + } + } - // this condition against the constraint we have. If the condition passes the constraint + console.log("Results", JSON.stringify(results)); return null; } @@ -318,37 +296,6 @@ export class Introspector { return results; } - /** - * 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. - */ - #removeNode(node: Record, 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 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); - - // 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 condition, recurse - if (this.#objectDiscovery.isCondition(clone[type][i])) { - clone[type][i] = this.#removeNode(node, clone[type][i]); - } - } - - return this.#stripNullProps(clone); - } - /** * Removes all subrules from the provided condition. * @param haystack The condition to search in and remove all sub-rules from. @@ -379,716 +326,675 @@ export class Introspector { return this.#stripNullProps(clone); } - #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; + /** + * Converts a value to an array if it is not already an array. + * @param value The value to convert. + */ + #asArray(value: R | R[]): R[] { + return Array.isArray(value) ? value : [value]; } /** - * 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. - * @param constraint The constraint to introspect against. - * @param subjects The subjects to introspect for. - * @throws RuleTypeError if the rule is not granular + * todo test this and validate + * 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. */ - introspect( - rule: Rule, - constraint: Constraint, - subjects: string[] - ): IntrospectionResult { - // The ruleset needs to be granular for this operation to work - if (!this.#objectDiscovery.isGranular(rule)) { - throw new RuleTypeError("Introspection requires granular rules."); - } - - this.intrNew(rule, constraint, subjects); - - // Find any conditions which contain sub-rules - // We extract each sub-rule along with the parent rule that needs to pass in order - // for the subrule to be applicable. - // todo this is leaving the original sub rule in the parent rule - let subRuleResults: SubRuleResult[] = []; - for (const condition of this.#asArray(rule.conditions)) { - subRuleResults = subRuleResults.concat( - this.#findSubRules(condition, condition) - ); - } - - let results = this.#introspectRule(rule, constraint, subjects); - //console.log(JSON.stringify(results)); + #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(); - // 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); + // 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); } - - // 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, - }, - }, - constraint, - subjects - ) - ); } - return { - results, - ...("default" in rule && undefined !== rule.default - ? { default: rule.default } - : {}), - }; - } + const gap = " ".repeat(depth); + const msg = + 0 === depth + ? `\nIntrospecting "${this.#txtBold(type)}" condition` + : `${gap}--> "${this.#txtBold(type)}" condition`; + Logger.debug(msg); + + // Iterate over all grouped constraints + for (const [field, constraints] of groupedConst.entries()) { + // Prepare the local results + const candidates: Constraint[] = []; + + if (!parentType) { + if ("any" === type) + for (const c of constraints) { + this.#appendResult(parentResults, c); + + const gap = " ".repeat(depth); + const col = this.#txtCol(c.field, "g"); + const val = this.#txtCol(c.value, "y"); + const msg = ` ${gap}+ Adding '${col}'${c.operator}'${val}'`; + Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); + } - /** - * Runs the introspection process on a rule to determine the possible range of input criteria - * @param rule The rule to introspect. - * @param constraint The constraint to introspect against. - * @param subjects The subjects to introspect for. - */ - #introspectRule( - rule: Rule, - constraint: Constraint, - subjects: string[] - ): CriteriaRange[] { - // Initialize a clean steps array each time we introspect - this.#steps = []; - const conditions = this.#stripAllSubRules(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(); - for (const condition of conditions) { - const data = conditionMap.get(condition.result) ?? []; - if (!data.length) conditionMap.set(condition.result, data); + if ("all" === type) { + for (const c of constraints) { + // Test against the local results, if it fails, empty the results and return + if (!this.#test(candidates, input, c, type, depth)) { + Logger.debug(`${gap}X Exiting condition & discarding results...`); - data.push(condition); - } + // Stop processing condition & empty the results + return { stop: true, void: true }; + } - // 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: [] }); - } + // Append to local results + candidates.push(c); + } + } + } - // 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); - } + if ("any" == parentType) { + if ("any" === type) + for (const c of constraints) { + this.#appendResult(parentResults, c); - /** - * 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. - */ - #findSubRules( - condition: Condition, - root: Condition, - results: SubRuleResult[] = [] - ): SubRuleResult[] { - // Find the type of the condition - const type = this.#objectDiscovery.conditionType(condition); + const gap = " ".repeat(depth); + const col = this.#txtCol(c.field, "g"); + const val = this.#txtCol(c.value, "y"); + const msg = ` ${gap}+ Adding '${col}'${c.operator}'${val}'`; + Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); + } - // Iterate each node in the condition - for (const node of condition[type]) { - if (this.#objectDiscovery.isConditionWithResult(node)) { - results.push({ - parent: this.#removeSubRule(node, root), - subRule: { - conditions: this.#stripAllSubRules(node), - result: node.result, - }, - }); + if ("all" === type) { + for (const c of constraints) { + if (!this.#test(candidates, input, c, type, depth)) { + // Stop processing condition & DO NOT empty the parent results + return { stop: true, void: false }; + } - // Recursively find sub-rules within the sub-rule - for (const condition of this.#asArray(node)) { - results = this.#findSubRules(condition, root, results); + candidates.push(c); + } } - - return results; } - // Recursively find sub-rules within the condition - if (this.#objectDiscovery.isCondition(node)) { - results = this.#findSubRules(node, root, results); - } - } + if ("all" == parentType) { + if ("any" === type) { + // Track if all failed + let allFailed = true; + for (const c of constraints) { + // Test against the parent results, if it passes, append to parent results + const res = parentResults.get(field) ?? []; + if (this.#test(res, input, c, type, depth)) { + allFailed = false; + candidates.push(c); + } + } - return results; - } + // Stop processing condition & empty the results + if (allFailed) return { stop: true, void: true }; + } - /** - * 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. - */ - #removeSubRule( - needle: WithRequired, - haystack: Condition - ): Condition { - // Clone the root condition so that we can modify it - const clone = JSON.parse(JSON.stringify(haystack)); + if ("all" === type) { + for (const c of constraints) { + // Get parent results for the field + const results = parentResults.get(field) ?? []; - // Find the type of the condition - const type = this.#objectDiscovery.conditionType(clone); + // Test against local and parent results, if any fail, empty parent results and return + if (!this.#test(candidates, input, c, type, depth)) { + Logger.debug(`${gap}X Exiting condition & discarding results...`); - // Iterate over each node in the condition - for (let i = 0; i < clone[type].length; i++) { - const node = clone[type][i]; + // Stop processing condition & empty the results + return { stop: true, void: true }; + } - // If the node is a condition, recurse - if (this.#objectDiscovery.isCondition(node)) { - clone[type][i] = this.#removeSubRule(needle, node); - continue; - } + if (!this.#test(results, input, c, type, depth)) { + Logger.debug(`${gap}X Exiting condition & discarding results...`); - // If the node is a sub-rule - if (this.#objectDiscovery.isConditionWithResult(node)) { - if (!this.existsIn(needle, node)) { - clone[type].splice(i, 1); - continue; - } + // Stop processing condition & empty the results + return { stop: true, void: true }; + } - // Otherwise, recurse into the sub-rule - const conditions = this.#asArray(node); - for (let j = 0; j < conditions.length; j++) { - clone[type][i].rule.conditions[j] = this.#removeSubRule( - needle, - conditions[j] - ); + // Append to local results + candidates.push(c); + } } } - } - - return clone; - } - /** - * 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. - */ - existsIn( - needle: WithRequired, - haystack: unknown, - found: boolean = false - ): boolean { - if (found) return true; + // Add the local results to the parent results + this.#sanitizeCandidates(candidates, depth).forEach((c) => + this.#appendResult(parentResults, c) + ); + } - // Otherwise, recurse into the sub-rule - for (const node of this.#asArray(haystack)) { - // If the node is a sub-rule - if (this.#objectDiscovery.isConditionWithResult(node)) { - // Check if it is the sub-rule we are looking for - if (JSON.stringify(needle) === JSON.stringify(node)) return true; - // Otherwise, recurse into the sub-rule - found = this.existsIn(needle, node, found); + // Log the results + for (const [k, v] of parentResults.entries()) { + const values = []; + for (const c of v) { + values.push(`${c.operator}${this.#txtCol(c.value, "y")}`); } - // 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); - } + const msg = ` ${gap}- ${this.#txtCol("Results", "m")} `; + Logger.debug(`${msg}${this.#txtCol(k, "g")}: ${values.join(", ")}`); } - return found; - } + // 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); - /** - * Removes all sub-rules from a condition or array of conditions. - * @param conditions The conditions to remove 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); - } + if (res.void) parentResults = new Map(); + if (res.stop) + return { values: parentResults, stop: res.stop, void: res.void }; - // If the node is a sub-rule, remove it - if (this.#objectDiscovery.isConditionWithResult(node)) - clone[i][type].splice(j, 1); - } - } - - return Array.isArray(conditions) ? (clone as R) : (clone[0] as R); - } - - /** - * Flattens a condition or set of conditions into a single object. - * @param conditions The conditions to flatten. - * @param result The result object to populate. - */ - #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))); - - for (const condition of clone) { - const items = []; - - const type = this.#objectDiscovery.conditionType(condition); - for (const node of condition[type]) { - if (this.#objectDiscovery.isConditionWithResult(node)) { - delete node.result; - result = this.#flatten(node, result); - continue; + if (res?.values) { + for (const constraints of res.values.values()) { + constraints.forEach((c) => this.#appendResult(parentResults, c)); } - - items.push(node); } - - result.all.push({ [type]: items }); } - return result; + return { values: parentResults, stop: false, void: false }; } /** - * 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. + * 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. */ - #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(); - } + #appendResult(map: Map, c: Constraint): void { + const temp = map.get(c.field) ?? []; + map.set(c.field, temp); - // 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; - } - } - } + // Do not add duplicate constraints + if (temp.some((t) => JSON.stringify(t) === JSON.stringify(c))) return; - return criteriaRanges; + temp.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. + * todo test this and validate + * @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 type The type of the condition holding testConst. * @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>(); - - for (const node of condition[type]) { - if (!this.#objectDiscovery.isConstraint(node)) continue; - - const constraint = node as Constraint; + #test( + candidates: Constraint[], + input: Omit, + item: Constraint, + type: "any" | "all", + depth: number + ): boolean { + // Filter out results which do not match the field of the constraint + candidates = candidates.filter((r) => r.field === item.field); - Logger.debug( - `Introspector: Processing "${constraint.field} (${constraint.operator})" in "${type}" condition` - ); + // Add the input constraint to the results (if it also matches the field) + if (input.field === item.field) + candidates.push({ ...input, operator: "==" }); - // 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); - } + // Test that the constraint does not breach the results + let result = false; - 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 the type is any, we can just add the constraint to the results + if ("any" === type) result = true; 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; - } + if (!candidates.length) result = true; - return prev; - }, {}), - depth - ); + for (const c of candidates) { + let ops: any; - // Debug the last introspection - Logger.debug("Introspector: Step complete", this.lastStep); - } - - return criteriaRange; - } + // Extract the item properties to test with + const { value, operator } = item; - /** - * 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. - */ - #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")) { - switch (c.operator) { - case "==": - c.operator = "!="; - break; - case "!=": - c.operator = "=="; - break; - case ">": - c.operator = "<="; - break; - case "<": - c.operator = ">="; - break; - case ">=": - c.operator = "<"; - break; - case "<=": - c.operator = ">"; - break; - case "in": - c.operator = "not in"; - 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; + /** + * 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 irrelevant of the operator + ops = ["==", ">=", "<="]; + if (ops.includes(operator) && value === c.value) result = true; + + // Item value must allow for constraint value to exist in item value range + if ("<" === operator && value > c.value) result = true; + if (">" === operator && value < c.value) result = true; + + // Item value cannot be equal to constraint value + if ("!=" === operator && value !== c.value) result = true; + + // 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 = true; + } + + // 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 = true; + } break; - default: - option[c.field] = { - operator: c.operator, - value: c.value, - }; + case "!=": + /** + * 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 = true; + + // 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 = true; + } break; - } + case ">": + /** + * c = (L > 500) + * L == 501↑ + * L != any + * L >= any + * L <= 501↑ + * L > any + * L < 502↑ + * IN [501, 502] + * NOT IN [501, 502] + */ + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value > c.value) result = true; + + if ("<" === operator && Number(value) > Number(c.value) + 2) + result = true; + + // Always pass + ops = ["!=", ">", ">=", "not in"]; + if (ops.includes(operator)) result = true; + + // 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 = true; + } + break; + case "<": + /** + * c = (L < 500) + * L == 499↓ + * L != any + * L >= 499↓ + * L <= any + * L > 498↓ + * L < any + * IN [499, 500] + */ + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value < c.value) result = true; + + if (">" === operator && Number(value) < Number(c.value) - 2) + result = true; + + // Always pass + ops = ["!=", "<", "<=", "not in"]; + if (ops.includes(operator)) result = true; + + // 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 = true; + } + break; + case ">=": + /** + * c = (L >= 500) + * L == 500↑ + * L != any + * L >= any + * L <= 500↑ + * L > any + * L < 501↑ + * L IN [500, 501] + */ + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value >= c.value) result = true; + + if ("<" === operator && Number(value) >= Number(c.value) + 1) + result = true; + + // Always pass + ops = ["!=", ">=", ">", "not in"]; + if ("!=" === operator) result = true; + + // 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 = true; + } + break; + case "<=": + /** + * c = (L <= 500) + * L == 500↓ + * L != any + * L >= 500↓ + * L <= any + * L > 499↓ + * L < any + */ + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value <= c.value) result = true; + + if (">" === operator && Number(value) >= Number(c.value) - 1) + result = true; + + // Always pass + ops = ["!=", "<=", "<", "not in"]; + if ("!=" === operator) result = true; + + // 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 = true; + } + break; + case "in": + /** + * c = (L [500,501) + * IN [500, 502] + * NOT IN [499, 500] + */ + + // 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 irrelevant of the operator + ops = ["==", ">=", "<="]; + if (ops.includes(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; + } - return option; - } + // One of the values in the item must match any candidate values + const inValues = this.#asArray(c.value); + if ("in" === operator) { + if (this.#asArray(value).some((val) => inValues.includes(val))) + result = true; + } - // 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 ("not in" === operator) { + if (this.#asArray(value).some((val) => !inValues.includes(val))) + result = true; + } + break; + case "not in": + /** + * c = (L NOT IN [500,501) + * IN [499, 501] + * NOT IN [500, 499] + */ + + // Always pass + if ("not in" === operator) result = true; + + // For each item run the same checks as for the '!=' operator + for (const subVal of this.#asArray(c.value)) { + // Must be different + if ("==" === operator && value !== subVal) result = true; + + // Always pass + ops = ["!=", ">", ">=", "<", "<=", "not in"]; + if (ops.includes(operator)) result = true; + } + + // One of the values in the item must NOT match any candidate values + const nInValues = this.#asArray(c.value); + if ("in" === operator) { + if (this.#asArray(value).some((val) => !nInValues.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 = this.#txtCol(item.field, "g"); + const val = this.#txtCol(item.value, "y"); + const pass = this.#txtCol("pass", "g"); + const fail = this.#txtCol("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. + * + * @param candidates 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(); - } - } + #sanitizeCandidates(candidates: Constraint[], depth: number): Constraint[] { + if (candidates.length < 2) return candidates; - for (const [key, value] of Object.entries(option)) { - baseOption[key] = baseOption.hasOwnProperty(key) - ? [...new Set([baseOption[key], value].flat())] - : value; - } - - this.#steps.push({ - parentType, - currType, - depth, - option: baseOption, - }); + const gap = " ".repeat(depth); + const msg = ` ${gap}> ${this.#txtCol("Sanitizing", "b")}:`; - Logger.debug( - `Introspector: + new option to criteria range based on last root parent` - ); + const values = []; + for (const c of candidates) { + values.push(`${c.operator}${this.#txtCol(c.value, "m")}`); + } - entry.options.push(baseOption); - return entry; - } + Logger.debug(`${msg} ${values.join(", ")}`); + return candidates; + } - this.#steps.push({ parentType, currType, depth, option }); - Logger.debug(`Introspector: + new option to criteria range`); + /** + * Formats text with color. + * @param text The text to colorize. + * @param color The color to apply. + */ + #txtCol(text: any, 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(); + } - entry.options.push(option); - return entry; - } + /** + * Formats text as bold. + * @param text The text to bold. + */ + #txtBold(text: any): string { + return `\x1b[1m${text}\x1b[0m`; + } - // 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; - - changes.push({ key, value }); - } + /** + * 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. + */ + #removeNode(node: Record, haystack: Condition): Condition { + // Clone the condition so that we can modify it + const clone = JSON.parse(JSON.stringify(haystack)); - this.#steps.push({ - parentType, - currType, - depth, - option: entry.options[lastIdx], - changes, - }); + // 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); - Logger.debug(`Introspector: Updating previous option with new values"`); + // If the node is now empty, we can prune it + if (Array.isArray(clone[type]) && !clone[type].length) return null; + continue; + } - return entry; + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(clone[type][i])) { + clone[type][i] = this.#removeNode(node, clone[type][i]); + } } - this.#steps.push({ parentType, currType, depth, option }); - Logger.debug(`Introspector: + new option to criteria range`); - - entry.options.push(option); - return entry; + return this.#stripNullProps(clone); } /** - * 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. + * 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. */ - #isCurrentStepChildOf(parentType: ConditionType): boolean { - if (!this.#steps?.length) { - return false; - } - - // Clone the steps array so that we can pop items off it - const steps = [...this.#steps]; - let step = steps.pop(); + #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]; - // Check ancestors until we reach the first root condition - while (step?.depth >= 0) { - if (step.currType === parentType || step.parentType === parentType) + // 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; + } - step = steps.pop(); + // If the node is a condition, recurse + if (this.#objectDiscovery.isCondition(node)) { + return this.#hasConstraintField(field, node); + } } return false; } /** - * Converts a value to an array if it is not already an array. - * @param value The value to convert. + * Algorithm: + * -> Prepare all constraints as array + * -> Prepare all conditions as array + * -> 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) + * -> test each const against global group results or subject value + * -> if passes + * -> add to global group results + * -> if fails + * -> do not add + * -> if all fail + * -> empty global group results + * -> 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 */ - #asArray(value: R | R[]): R[] { - return Array.isArray(value) ? value : [value]; - } - - /** Returns the last step in the introspection process. */ - get lastStep(): IntrospectionStep | null { - return this.#steps?.length ? this.#steps[this.#steps.length - 1] : null; - } } diff --git a/src/services/rule-pilot.ts b/src/services/rule-pilot.ts index 39cde93..c15c2af 100644 --- a/src/services/rule-pilot.ts +++ b/src/services/rule-pilot.ts @@ -2,9 +2,9 @@ import { Mutator } from "./mutator"; import { Builder } from "../builder"; import { RuleError } from "../errors"; import { Evaluator } from "./evaluator"; +import { Rule, Constraint } from "../types"; import { Introspector } from "./introspector"; import { Validator, ValidationResult } from "./validator"; -import { Rule, Constraint, IntrospectionResult } from "../types"; export class RulePilot { static #rulePilot = new RulePilot(); @@ -98,18 +98,18 @@ export class RulePilot { * @throws RuleError if the rule is invalid * @throws RuleTypeError if the rule is not granular */ - introspect( + introspect( rule: Rule, - constraint: Constraint, + constraint: Omit, subjects: string[] - ): IntrospectionResult { + ): Record[]> { // 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, constraint, subjects); + return this.#introspector.introspect(rule, constraint, subjects); } /** @@ -162,12 +162,12 @@ export class RulePilot { * @throws RuleError if the rule is invalid * @throws RuleTypeError if the rule is not granular */ - static introspect( + static introspect( rule: Rule, - constraint: Constraint, + constraint: Omit, subjects: string[] - ): IntrospectionResult { - return RulePilot.#rulePilot.introspect(rule, constraint, subjects); + ): Record[]> { + return RulePilot.#rulePilot.introspect(rule, constraint, subjects); } /** diff --git a/src/types/index.ts b/src/types/index.ts index 46c200a..4620cce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1 @@ export * from "./rule"; -export * from "./introspection"; diff --git a/src/types/introspection.ts b/src/types/introspection.ts deleted file mode 100644 index a87fab1..0000000 --- a/src/types/introspection.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CriteriaRange { - result: R; - options?: Record[]; -} - -export interface IntrospectionResult { - results: CriteriaRange[]; - default?: R; -} From f398a3cd85c5dede4bde7c92b9feca27846e8d3f Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Mon, 11 Nov 2024 18:10:02 +0100 Subject: [PATCH 07/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 47 ++++++++++++++++++++++++++---------- src/services/rule-pilot.ts | 6 ++--- src/types/index.ts | 1 + src/types/introspection.ts | 5 ++++ 4 files changed, 43 insertions(+), 16 deletions(-) create mode 100644 src/types/introspection.ts diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 49539a4..582947d 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -1,6 +1,12 @@ import { Logger } from "./logger"; import { ObjectDiscovery } from "./object-discovery"; -import { Rule, Condition, Constraint } from "../types"; +import { + Rule, + Condition, + Constraint, + ConditionType, + IntrospectionResult, +} from "../types"; interface SubRuleResult { parent?: Condition; @@ -25,7 +31,7 @@ export class Introspector { rule: Rule, constraint: Omit, subjects: string[] - ): Record[]> { + ): 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. @@ -55,9 +61,16 @@ export class Introspector { } subRules.forEach((rule) => { - conditions.push( - rule.parent ? { all: [rule.parent, rule.subRule] } : rule.subRule - ); + if (!rule.parent) { + conditions.push(rule.subRule); + return; + } + + const result = rule.subRule.result; + delete rule.parent.result; + delete rule.subRule.result; + + conditions.push({ all: [rule.parent, rule.subRule], result }); }); // todo remove @@ -74,6 +87,9 @@ export class Introspector { const { values } = this.#introspectConditions(condition, constraint); if (!values) continue; + const key = condition.result ?? "default"; + results[key] = results[key] ?? {}; + // Merge the results maintaining the uniqueness of the values for (const [field, constraints] of values.entries()) { if (!subjects.includes(field)) continue; @@ -83,7 +99,7 @@ export class Introspector { set.add({ value: constraint.value, operator: constraint.operator }); } - results[field] = Array.from(set); + results[key][field] = Array.from(set); } } @@ -397,7 +413,7 @@ export class Introspector { for (const c of constraints) { // Test against the local results, if it fails, empty the results and return if (!this.#test(candidates, input, c, type, depth)) { - Logger.debug(`${gap}X Exiting condition & discarding results...`); + Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results return { stop: true, void: true }; @@ -457,14 +473,14 @@ export class Introspector { // Test against local and parent results, if any fail, empty parent results and return if (!this.#test(candidates, input, c, type, depth)) { - Logger.debug(`${gap}X Exiting condition & discarding results...`); + Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results return { stop: true, void: true }; } if (!this.#test(results, input, c, type, depth)) { - Logger.debug(`${gap}X Exiting condition & discarding results...`); + Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results return { stop: true, void: true }; @@ -477,7 +493,7 @@ export class Introspector { } // Add the local results to the parent results - this.#sanitizeCandidates(candidates, depth).forEach((c) => + this.#sanitizeCandidates(candidates, type, depth).forEach((c) => this.#appendResult(parentResults, c) ); } @@ -829,7 +845,7 @@ export class Introspector { const pass = this.#txtCol("pass", "g"); const fail = this.#txtCol("fail", "r"); - const msg = ` ${gap}> Testing '${col}' ${item.operator} '${val}'`; + const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; Logger.debug(msg, `(${result ? pass : fail})`); // Return the result @@ -839,13 +855,18 @@ export class Introspector { /** * * @param candidates The constraints to sanitize. + * @param type The type of the condition holding the constraints. * @param depth The current recursion depth. */ - #sanitizeCandidates(candidates: Constraint[], depth: number): Constraint[] { + #sanitizeCandidates( + candidates: Constraint[], + type: ConditionType, + depth: number + ): Constraint[] { if (candidates.length < 2) return candidates; const gap = " ".repeat(depth); - const msg = ` ${gap}> ${this.#txtCol("Sanitizing", "b")}:`; + const msg = ` ${gap}> ${this.#txtCol(`Sanitizing (${type})`, "b")}:`; const values = []; for (const c of candidates) { diff --git a/src/services/rule-pilot.ts b/src/services/rule-pilot.ts index c15c2af..ed05968 100644 --- a/src/services/rule-pilot.ts +++ b/src/services/rule-pilot.ts @@ -2,9 +2,9 @@ import { Mutator } from "./mutator"; import { Builder } from "../builder"; import { RuleError } from "../errors"; import { Evaluator } from "./evaluator"; -import { Rule, Constraint } from "../types"; import { Introspector } from "./introspector"; import { Validator, ValidationResult } from "./validator"; +import { Rule, Constraint, IntrospectionResult } from "../types"; export class RulePilot { static #rulePilot = new RulePilot(); @@ -102,7 +102,7 @@ export class RulePilot { rule: Rule, constraint: Omit, subjects: string[] - ): Record[]> { + ): IntrospectionResult { // Before we proceed with the rule, we should validate it. const validationResult = this.validate(rule); if (!validationResult.isValid) { @@ -166,7 +166,7 @@ export class RulePilot { rule: Rule, constraint: Omit, subjects: string[] - ): Record[]> { + ): IntrospectionResult { return RulePilot.#rulePilot.introspect(rule, constraint, subjects); } diff --git a/src/types/index.ts b/src/types/index.ts index 4620cce..46c200a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1 +1,2 @@ export * from "./rule"; +export * from "./introspection"; diff --git a/src/types/introspection.ts b/src/types/introspection.ts new file mode 100644 index 0000000..79d12f4 --- /dev/null +++ b/src/types/introspection.ts @@ -0,0 +1,5 @@ +import { Constraint } from "./rule"; + +export interface IntrospectionResult { + [key: string]: Omit[]; +} From c08917170567a3af72f1a5b3f982448482d78a09 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Mon, 11 Nov 2024 21:53:44 +0100 Subject: [PATCH 08/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 581 ++++++++++++++++++----------------- 1 file changed, 301 insertions(+), 280 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 582947d..7f1894a 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -1,12 +1,6 @@ import { Logger } from "./logger"; import { ObjectDiscovery } from "./object-discovery"; -import { - Rule, - Condition, - Constraint, - ConditionType, - IntrospectionResult, -} from "../types"; +import { Rule, Condition, Constraint, IntrospectionResult } from "../types"; interface SubRuleResult { parent?: Condition; @@ -412,7 +406,7 @@ export class Introspector { if ("all" === type) { for (const c of constraints) { // Test against the local results, if it fails, empty the results and return - if (!this.#test(candidates, input, c, type, depth)) { + if (!this.#test(candidates, input, c, depth)) { Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results @@ -426,7 +420,7 @@ export class Introspector { } if ("any" == parentType) { - if ("any" === type) + if ("any" === type) { for (const c of constraints) { this.#appendResult(parentResults, c); @@ -436,10 +430,11 @@ export class Introspector { const msg = ` ${gap}+ Adding '${col}'${c.operator}'${val}'`; Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); } + } if ("all" === type) { for (const c of constraints) { - if (!this.#test(candidates, input, c, type, depth)) { + if (!this.#test(candidates, input, c, depth)) { // Stop processing condition & DO NOT empty the parent results return { stop: true, void: false }; } @@ -456,7 +451,7 @@ export class Introspector { for (const c of constraints) { // Test against the parent results, if it passes, append to parent results const res = parentResults.get(field) ?? []; - if (this.#test(res, input, c, type, depth)) { + if (this.#test([...candidates, ...res], input, c, depth)) { allFailed = false; candidates.push(c); } @@ -472,14 +467,14 @@ export class Introspector { const results = parentResults.get(field) ?? []; // Test against local and parent results, if any fail, empty parent results and return - if (!this.#test(candidates, input, c, type, depth)) { + if (!this.#test(candidates, input, c, depth)) { Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results return { stop: true, void: true }; } - if (!this.#test(results, input, c, type, depth)) { + if (!this.#test(results, input, c, depth)) { Logger.debug(`${gap}X Exiting & discarding results...`); // Stop processing condition & empty the results @@ -493,7 +488,7 @@ export class Introspector { } // Add the local results to the parent results - this.#sanitizeCandidates(candidates, type, depth).forEach((c) => + this.#sanitizeCandidates(candidates, depth).forEach((c) => this.#appendResult(parentResults, c) ); } @@ -505,7 +500,7 @@ export class Introspector { values.push(`${c.operator}${this.#txtCol(c.value, "y")}`); } - const msg = ` ${gap}- ${this.#txtCol("Results", "m")} `; + const msg = ` ${gap}${this.#txtCol("* Results", "m")} `; Logger.debug(`${msg}${this.#txtCol(k, "g")}: ${values.join(", ")}`); } @@ -550,291 +545,285 @@ export class Introspector { * @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 type The type of the condition holding testConst. * @param depth The current recursion depth. */ #test( candidates: Constraint[], input: Omit, item: Constraint, - type: "any" | "all", depth: number ): boolean { // Filter out results which do not match the field of the constraint candidates = candidates.filter((r) => r.field === item.field); + // Check if the input constraint matches the field of the item + const inputMatches = input.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: "==" }); + if (inputMatches) candidates.push({ ...input, operator: "==" }); + + if (!candidates.length) return true; // Test that the constraint does not breach the results let result = false; + for (const c of candidates) { + let ops: any; + + // Extract the item properties to test with + const { value, operator } = item; + + switch (c.operator) { + case "==": + /** + * 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 irrelevant of the operator + ops = ["==", ">=", "<="]; + if (ops.includes(operator) && value === c.value) result = true; + + // Item value must allow for constraint value to exist in item value range + if ("<" === operator && value > c.value) result = true; + if (">" === operator && value < c.value) result = true; + + // Item value cannot be equal to constraint value + if ("!=" === operator && value !== c.value) result = true; + + // 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 = true; + } - // If the type is any, we can just add the constraint to the results - if ("any" === type) result = true; - - if ("all" === type) { - if (!candidates.length) 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 = (L == 500) - * L == 500 - * L != !500 - * L >= 500 - * L <= 500 - * L > 499 - * L < 501 - * L IN [500] - * L NOT IN [501, 502] - */ - + // 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 = true; + } + break; + case "!=": + /** + * 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 = true; + + // 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 = true; + } + break; + case ">": + /** + * c = (L > 500) + * L == 501↑ + * L != any + * L >= any + * L <= 501↑ + * L > any + * L < 502↑ + * IN [501, 502] + * NOT IN [501, 502] + */ + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value > c.value) result = true; + + if ("<" === operator && Number(value) > Number(c.value) + 2) + result = true; + + // Always pass + ops = ["!=", ">", ">=", "not in"]; + if (ops.includes(operator)) result = true; + + // 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 = true; + } + break; + case "<": + /** + * c = (L < 500) + * L == 499↓ + * L != any + * L >= 499↓ + * L <= any + * L > 498↓ + * L < any + * IN [499, 500] + */ + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value < c.value) result = true; + + if (">" === operator && Number(value) < Number(c.value) - 2) + result = true; + + // Always pass + ops = ["!=", "<", "<=", "not in"]; + if (ops.includes(operator)) result = true; + + // 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 = true; + } + break; + case ">=": + /** + * c = (L >= 500) + * L == 500↑ + * L != any + * L >= any + * L <= 500↑ + * L > any + * L < 501↑ + * L IN [500, 501] + */ + + // Must be bigger than the value + ops = ["==", "<="]; + if (ops.includes(operator) && value >= c.value) result = true; + + if ("<" === operator && Number(value) >= Number(c.value) + 1) + result = true; + + // Always pass + ops = ["!=", ">=", ">", "not in"]; + if ("!=" === operator) result = true; + + // 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 = true; + } + break; + case "<=": + /** + * c = (L <= 500) + * L == 500↓ + * L != any + * L >= 500↓ + * L <= any + * L > 499↓ + * L < any + */ + + // Must be smaller than the value + ops = ["==", ">="]; + if (ops.includes(operator) && value <= c.value) result = true; + + if (">" === operator && Number(value) >= Number(c.value) - 1) + result = true; + + // Always pass + ops = ["!=", "<=", "<", "not in"]; + if ("!=" === operator) result = true; + + // 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 = true; + } + break; + case "in": + /** + * c = (L [500,501) + * IN [500, 502] + * NOT IN [499, 500] + */ + + // 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 irrelevant of the operator ops = ["==", ">=", "<="]; - if (ops.includes(operator) && value === c.value) result = true; + if (ops.includes(operator) && value === subVal) result = true; // Item value must allow for constraint value to exist in item value range - if ("<" === operator && value > c.value) result = true; - if (">" === operator && value < c.value) result = true; + if ("<" === operator && value > subVal) result = true; + if (">" === operator && value < subVal) result = true; // Item value cannot be equal to constraint value - if ("!=" === operator && value !== c.value) result = true; - - // 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 = true; - } - - // 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 = true; - } - break; - case "!=": - /** - * 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 = true; - - // Always pass - ops = ["!=", ">", ">=", "<", "<=", "not in"]; - if (ops.includes(operator)) result = true; + if ("!=" === operator && value !== subVal) 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 = true; - } - break; - case ">": - /** - * c = (L > 500) - * L == 501↑ - * L != any - * L >= any - * L <= 501↑ - * L > any - * L < 502↑ - * IN [501, 502] - * NOT IN [501, 502] - */ - - // Must be bigger than the value - ops = ["==", "<="]; - if (ops.includes(operator) && value > c.value) result = true; - - if ("<" === operator && Number(value) > Number(c.value) + 2) + // One of the values in the item must match any candidate values + const inValues = this.#asArray(c.value); + if ("in" === operator) { + if (this.#asArray(value).some((val) => inValues.includes(val))) result = true; + } - // Always pass - ops = ["!=", ">", ">=", "not in"]; - if (ops.includes(operator)) result = true; - - // 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 = true; - } - break; - case "<": - /** - * c = (L < 500) - * L == 499↓ - * L != any - * L >= 499↓ - * L <= any - * L > 498↓ - * L < any - * IN [499, 500] - */ - - // Must be smaller than the value - ops = ["==", ">="]; - if (ops.includes(operator) && value < c.value) result = true; - - if (">" === operator && Number(value) < Number(c.value) - 2) + // 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 = (L NOT IN [500,501) + * IN [499, 501] + * NOT IN [500, 499] + */ + + // Always pass + if ("not in" === operator) result = true; + + // For each item run the same checks as for the '!=' operator + for (const subVal of this.#asArray(c.value)) { + // Must be different + if ("==" === operator && value !== subVal) result = true; // Always pass - ops = ["!=", "<", "<=", "not in"]; + ops = ["!=", ">", ">=", "<", "<=", "not in"]; if (ops.includes(operator)) result = true; + } - // 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 = true; - } - break; - case ">=": - /** - * c = (L >= 500) - * L == 500↑ - * L != any - * L >= any - * L <= 500↑ - * L > any - * L < 501↑ - * L IN [500, 501] - */ - - // Must be bigger than the value - ops = ["==", "<="]; - if (ops.includes(operator) && value >= c.value) result = true; - - if ("<" === operator && Number(value) >= Number(c.value) + 1) - result = true; - - // Always pass - ops = ["!=", ">=", ">", "not in"]; - if ("!=" === operator) result = true; - - // 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 = true; - } - break; - case "<=": - /** - * c = (L <= 500) - * L == 500↓ - * L != any - * L >= 500↓ - * L <= any - * L > 499↓ - * L < any - */ - - // Must be smaller than the value - ops = ["==", ">="]; - if (ops.includes(operator) && value <= c.value) result = true; - - if (">" === operator && Number(value) >= Number(c.value) - 1) + // One of the values in the item must NOT match any candidate values + const nInValues = this.#asArray(c.value); + if ("in" === operator) { + if (this.#asArray(value).some((val) => !nInValues.includes(val))) result = true; - - // Always pass - ops = ["!=", "<=", "<", "not in"]; - if ("!=" === operator) result = true; - - // 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 = true; - } - break; - case "in": - /** - * c = (L [500,501) - * IN [500, 502] - * NOT IN [499, 500] - */ - - // 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 irrelevant of the operator - ops = ["==", ">=", "<="]; - if (ops.includes(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; - } - - // One of the values in the item must match any candidate values - const inValues = this.#asArray(c.value); - 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 = (L NOT IN [500,501) - * IN [499, 501] - * NOT IN [500, 499] - */ - - // Always pass - if ("not in" === operator) result = true; - - // For each item run the same checks as for the '!=' operator - for (const subVal of this.#asArray(c.value)) { - // Must be different - if ("==" === operator && value !== subVal) result = true; - - // Always pass - ops = ["!=", ">", ">=", "<", "<=", "not in"]; - if (ops.includes(operator)) result = true; - } - - // One of the values in the item must NOT match any candidate values - const nInValues = this.#asArray(c.value); - if ("in" === operator) { - if (this.#asArray(value).some((val) => !nInValues.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; - } + } + break; + // case "contains": + // break; + // case "not contains": + // break; + // case "contains any": + // break; + // case "not contains any": + // break; + // case "matches": + // break; + // case "not matches": + // break; } } @@ -855,26 +844,57 @@ export class Introspector { /** * * @param candidates The constraints to sanitize. - * @param type The type of the condition holding the constraints. * @param depth The current recursion depth. */ - #sanitizeCandidates( - candidates: Constraint[], - type: ConditionType, - depth: number - ): Constraint[] { + #sanitizeCandidates(candidates: Constraint[], depth: number): Constraint[] { + // If the list less than 2 items, we can return it as is if (candidates.length < 2) return candidates; const gap = " ".repeat(depth); - const msg = ` ${gap}> ${this.#txtCol(`Sanitizing (${type})`, "b")}:`; + const msg = ` ${gap}> ${this.#txtCol(`Sanitizing`, "b")}:`; const values = []; for (const c of candidates) { values.push(`${c.operator}${this.#txtCol(c.value, "m")}`); } - Logger.debug(`${msg} ${values.join(", ")}`); - return candidates; + // Flag to indicate if the list has been modified + let modified = false; + + // Search for candidates with <,>,<=,>= operators + for (const c of candidates) { + if (["<", ">", "<=", ">="].includes(c.operator)) { + const index = candidates.indexOf(c); + const val = c.value; + const op = c.operator; + + for (let i = 0; i < candidates.length; i++) { + if (i === index) continue; + + const item = candidates[i]; + if (item.operator === "==") { + if (["<=", ">="].includes(op)) { + delete candidates[i]; + modified = true; + } + + if ("<" === op && item.value < val) { + delete candidates[i]; + modified = true; + } + + if (">" === op && item.value > val) { + console.log("sdasd"); + delete candidates[i]; + modified = true; + } + } + } + } + } + + !modified && Logger.debug(`${msg} ${values.join(", ")}`); + return modified ? this.#sanitizeCandidates(candidates, depth) : candidates; } /** @@ -960,6 +980,7 @@ export class Introspector { * 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 @@ -992,13 +1013,13 @@ export class Introspector { * -> If parent is all * -> if type is any * -> group const by field (and foreach group) - * -> test each const against global group results or subject value + * -> bal group results or subject value * -> if passes * -> add to global group results * -> if fails * -> do not add * -> if all fail - * -> empty global group results + * -> 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) From bfb748f08363d97adb2e2f9bbba5706753571452 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 10:54:53 +0100 Subject: [PATCH 09/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 185 +++++++++++++++---------- test/rulesets/sub-rules-valid2.json.ts | 18 ++- 2 files changed, 130 insertions(+), 73 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 7f1894a..e481e4e 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -487,6 +487,15 @@ export class Introspector { } } + const existing = (parentResults.get(field) ?? []).map( + (r) => `${r.operator}${this.#txtCol(r.value, "m")}` + ); + Logger.debug( + ` ${gap}> ${this.#txtCol("Merging into", "b")}: [${existing.join( + ", " + )}]` + ); + // Add the local results to the parent results this.#sanitizeCandidates(candidates, depth).forEach((c) => this.#appendResult(parentResults, c) @@ -562,10 +571,24 @@ export class Introspector { // Add the input constraint to the results (if it also matches the field) if (inputMatches) candidates.push({ ...input, operator: "==" }); - if (!candidates.length) return true; + // Prepare the log + const gap = " ".repeat(depth); + const col = this.#txtCol(item.field, "g"); + const val = this.#txtCol(item.value, "y"); + const pass = this.#txtCol("pass", "g"); + const fail = this.#txtCol("fail", "r"); + + const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; + + if (!candidates.length) { + Logger.debug(msg, `(${pass})`); + return true; + } // Test that the constraint does not breach the results - let result = false; + // 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; @@ -588,25 +611,25 @@ export class Introspector { // Must be equal to the value irrelevant of the operator ops = ["==", ">=", "<="]; - if (ops.includes(operator) && value === c.value) result = true; + if (ops.includes(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 = true; - if (">" === operator && value < c.value) result = true; + 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 = true; + 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 = true; + 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 = true; + if (this.#asArray(value).some((val) => val === c.value)) + result = false; } break; case "!=": @@ -623,16 +646,16 @@ export class Introspector { */ // Must be different - if ("==" === operator && value !== c.value) result = true; + if ("==" === operator && value === c.value) result = false; - // Always pass - ops = ["!=", ">", ">=", "<", "<=", "not in"]; - if (ops.includes(operator)) result = true; + // // 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 = true; + if (!this.#asArray(value).some((val) => val !== c.value)) + result = false; } break; case ">": @@ -650,19 +673,19 @@ export class Introspector { // Must be bigger than the value ops = ["==", "<="]; - if (ops.includes(operator) && value > c.value) result = true; + if (ops.includes(operator) && value <= c.value) result = false; - if ("<" === operator && Number(value) > Number(c.value) + 2) - result = true; + if ("<" === operator && Number(value) <= Number(c.value) + 2) + result = false; - // Always pass - ops = ["!=", ">", ">=", "not in"]; - if (ops.includes(operator)) result = true; + // // Always pass + // ops = ["!=", ">", ">=", "not in"]; + // if (ops.includes(operator)) result = true; // 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 = true; + if (!this.#asArray(value).some((val) => val > c.value)) + result = false; } break; case "<": @@ -679,19 +702,19 @@ export class Introspector { // Must be smaller than the value ops = ["==", ">="]; - if (ops.includes(operator) && value < c.value) result = true; + if (ops.includes(operator) && value >= c.value) result = false; - if (">" === operator && Number(value) < Number(c.value) - 2) - result = true; + if (">" === operator && Number(value) >= Number(c.value) - 2) + result = false; - // Always pass - ops = ["!=", "<", "<=", "not in"]; - if (ops.includes(operator)) result = true; + // // Always pass + // ops = ["!=", "<", "<=", "not in"]; + // if (ops.includes(operator)) result = true; // 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 = true; + if (!this.#asArray(value).some((val) => val < c.value)) + result = false; } break; case ">=": @@ -708,19 +731,19 @@ export class Introspector { // Must be bigger than the value ops = ["==", "<="]; - if (ops.includes(operator) && value >= c.value) result = true; + if (ops.includes(operator) && value < c.value) result = false; - if ("<" === operator && Number(value) >= Number(c.value) + 1) - result = true; + if ("<" === operator && Number(value) < Number(c.value) + 1) + result = false; // Always pass - ops = ["!=", ">=", ">", "not in"]; - if ("!=" === operator) result = true; + // ops = ["!=", ">=", ">", "not in"]; + // if ("!=" === operator) result = true; // 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 = true; + if (!this.#asArray(value).some((val) => val >= c.value)) + result = false; } break; case "<=": @@ -736,19 +759,19 @@ export class Introspector { // Must be smaller than the value ops = ["==", ">="]; - if (ops.includes(operator) && value <= c.value) result = true; + if (ops.includes(operator) && value > c.value) result = false; - if (">" === operator && Number(value) >= Number(c.value) - 1) - result = true; + if (">" === operator && Number(value) < Number(c.value) - 1) + result = false; - // Always pass - ops = ["!=", "<=", "<", "not in"]; - if ("!=" === operator) result = true; + // // Always pass + // ops = ["!=", "<=", "<", "not in"]; + // if ("!=" === operator) result = true; // 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 = true; + if (!this.#asArray(value).some((val) => val <= c.value)) + result = false; } break; case "in": @@ -757,32 +780,49 @@ export class Introspector { * IN [500, 502] * NOT IN [499, 500] */ + let inSubRes = false; + let inChecked = false; // For each item run the same checks as for the '==' operator + // If none of the items pass, then fail for (const subVal of this.#asArray(c.value)) { // Must be equal to the value irrelevant of the operator ops = ["==", ">=", "<="]; - if (ops.includes(operator) && value === subVal) result = true; + if (ops.includes(operator) && value !== subVal) { + inSubRes = true; + inChecked = 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; + if ("<" === operator && value > subVal) { + inSubRes = true; + inChecked = true; + } + if (">" === operator && value < subVal) { + inSubRes = true; + inChecked = true; + } // Item value cannot be equal to constraint value - if ("!=" === operator && value !== subVal) result = true; + if ("!=" === operator && value !== subVal) { + inSubRes = true; + inChecked = true; + } } + if (inChecked && !inSubRes) result = false; + // One of the values in the item must match any candidate values const inValues = this.#asArray(c.value); if ("in" === operator) { - if (this.#asArray(value).some((val) => inValues.includes(val))) - result = true; + if (!this.#asArray(value).some((val) => inValues.includes(val))) + result = false; } // 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; + if (!this.#asArray(value).some((val) => !inValues.includes(val))) + result = false; } break; case "not in": @@ -792,24 +832,36 @@ export class Introspector { * NOT IN [500, 499] */ - // Always pass - if ("not in" === operator) result = true; + // // Always pass + // if ("not in" === operator) result = true; + + let notInSubRes = false; + let notInChecked = false; // For each item run the same checks as for the '!=' operator + // If none of the items pass, then fail for (const subVal of this.#asArray(c.value)) { // Must be different - if ("==" === operator && value !== subVal) result = true; + if ("==" === operator && value !== subVal) { + notInSubRes = true; + notInChecked = true; + } // Always pass ops = ["!=", ">", ">=", "<", "<=", "not in"]; - if (ops.includes(operator)) result = true; + if (ops.includes(operator)) { + notInSubRes = true; + notInChecked = true; + } } + if (notInChecked && !notInSubRes) result = false; + // One of the values in the item must NOT match any candidate values const nInValues = this.#asArray(c.value); if ("in" === operator) { - if (this.#asArray(value).some((val) => !nInValues.includes(val))) - result = true; + if (!this.#asArray(value).some((val) => !nInValues.includes(val))) + result = false; } break; // case "contains": @@ -827,14 +879,6 @@ export class Introspector { } } - // Prepare the log - const gap = " ".repeat(depth); - const col = this.#txtCol(item.field, "g"); - const val = this.#txtCol(item.value, "y"); - const pass = this.#txtCol("pass", "g"); - const fail = this.#txtCol("fail", "r"); - - const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; Logger.debug(msg, `(${result ? pass : fail})`); // Return the result @@ -884,7 +928,6 @@ export class Introspector { } if (">" === op && item.value > val) { - console.log("sdasd"); delete candidates[i]; modified = true; } @@ -893,7 +936,7 @@ export class Introspector { } } - !modified && Logger.debug(`${msg} ${values.join(", ")}`); + !modified && Logger.debug(`${msg} [${values.join(", ")}]`); return modified ? this.#sanitizeCandidates(candidates, depth) : candidates; } diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index f01df92..6f011a1 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -15,7 +15,7 @@ export const subRulesValid2Json: Rule = { }, { field: "Leverage", - operator: "==", + operator: "!=", value: 500, }, { @@ -30,8 +30,22 @@ export const subRulesValid2Json: Rule = { operator: "==", value: 500, }, + { + any: [ + { + field: "Leverage", + operator: "==", + value: 2000, + }, + { + field: "Leverage", + operator: "==", + value: 1500, + }, + ], + result: 100, + }, ], - result: 100, }, ], result: 50, From e39305627f88b781c21045c2abe1a372fc06936c Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 17:40:32 +0100 Subject: [PATCH 10/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 392 +++++++++++++++++++---------------- test/introspector.spec.ts | 284 ++++++++----------------- 2 files changed, 304 insertions(+), 372 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index e481e4e..d1dfb20 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -29,6 +29,9 @@ export class Introspector { // 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. @@ -67,7 +70,6 @@ export class Introspector { conditions.push({ all: [rule.parent, rule.subRule], result }); }); - // todo remove console.log("subRules", JSON.stringify(subRules)); console.log("conditions", JSON.stringify(conditions)); @@ -82,7 +84,6 @@ export class Introspector { if (!values) continue; const key = condition.result ?? "default"; - results[key] = results[key] ?? {}; // Merge the results maintaining the uniqueness of the values for (const [field, constraints] of values.entries()) { @@ -93,13 +94,15 @@ export class Introspector { set.add({ value: constraint.value, operator: constraint.operator }); } - results[key][field] = Array.from(set); + if (set.size) { + results[key] = {}; + results[key][field] = Array.from(set); + } } } console.log("Results", JSON.stringify(results)); - - return null; + return results; } /** @@ -130,8 +133,15 @@ export class Introspector { } if (shouldFlip) { - condition["all"] = condition[type]; - delete condition[type]; + if ("none" === type) { + condition["all"] = condition[type]; + delete condition[type]; + } + + if ("any" === type) { + condition["all"] = condition[type]; + delete condition[type]; + } } return this.#stripNullProps(condition); @@ -389,120 +399,94 @@ export class Introspector { // Iterate over all grouped constraints for (const [field, constraints] of groupedConst.entries()) { // Prepare the local results - const candidates: Constraint[] = []; + let candidates: Constraint[] = []; - if (!parentType) { - if ("any" === type) - for (const c of constraints) { - this.#appendResult(parentResults, c); + // Test the constraints and prepare the local results + //////////////////////////////////////////////////////////// + for (const c of constraints) { + // Append to local results + if ("any" === type) { + const col = this.#txtCol(c.field, "g"); + const val = this.#txtCol(c.value, "y"); + const msg = ` ${gap}+ Adding local '${col}'${c.operator}'${val}'`; + Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); - const gap = " ".repeat(depth); - const col = this.#txtCol(c.field, "g"); - const val = this.#txtCol(c.value, "y"); - const msg = ` ${gap}+ Adding '${col}'${c.operator}'${val}'`; - Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); - } + candidates.push(c); + } if ("all" === type) { - for (const c of constraints) { - // Test against the local results, if it fails, empty the results and return - if (!this.#test(candidates, input, c, depth)) { - Logger.debug(`${gap}X Exiting & discarding results...`); - - // Stop processing condition & empty the results - return { stop: true, void: true }; - } - - // Append to local results - candidates.push(c); + if (!this.#test(candidates, input, c, depth)) { + candidates = []; + Logger.debug(` ${gap}- Clearing all local`); + break; } + + candidates.push(c); } } + //////////////////////////////////////////////////////////// - if ("any" == parentType) { - if ("any" === type) { - for (const c of constraints) { - this.#appendResult(parentResults, c); + // Merge the local results with the parent results + //////////////////////////////////////////////////////////// + if (null === parentType) { + for (const c of candidates) this.#appendResult(parentResults, c); + } - const gap = " ".repeat(depth); - const col = this.#txtCol(c.field, "g"); - const val = this.#txtCol(c.value, "y"); - const msg = ` ${gap}+ Adding '${col}'${c.operator}'${val}'`; - Logger.debug(msg, `(${this.#txtCol("pass", "g")})`); - } + if ("any" === parentType) { + if ("any" === type) { + for (const c of candidates) this.#appendResult(parentResults, c); } if ("all" === type) { - for (const c of constraints) { - if (!this.#test(candidates, input, c, depth)) { - // Stop processing condition & DO NOT empty the parent results - return { stop: true, void: false }; - } - - candidates.push(c); + if (!candidates.length) { + Logger.debug(`${gap}X Exiting...`); + return { values: parentResults, stop: true, void: false }; } + + for (const c of candidates) this.#appendResult(parentResults, c); } } - if ("all" == parentType) { + if ("all" === parentType) { if ("any" === type) { - // Track if all failed - let allFailed = true; - for (const c of constraints) { - // Test against the parent results, if it passes, append to parent results - const res = parentResults.get(field) ?? []; - if (this.#test([...candidates, ...res], input, c, depth)) { - allFailed = false; - candidates.push(c); - } + const valid = []; + for (const c of candidates) { + const parentRes = parentResults.get(field) ?? []; + if (this.#test(parentRes, input, c, depth)) valid.push(c); + } + + if (!valid.length) { + Logger.debug(`${gap}X Exiting & Discarding results...`); + return { values: parentResults, stop: true, void: true }; } - // Stop processing condition & empty the results - if (allFailed) return { stop: true, void: true }; + for (const c of valid) this.#appendResult(parentResults, c); } if ("all" === type) { - for (const c of constraints) { - // Get parent results for the field - const results = parentResults.get(field) ?? []; - - // Test against local and parent results, if any fail, empty parent results and return - if (!this.#test(candidates, input, c, depth)) { - Logger.debug(`${gap}X Exiting & discarding results...`); - - // Stop processing condition & empty the results - return { stop: true, void: true }; - } - - if (!this.#test(results, input, c, depth)) { - Logger.debug(`${gap}X Exiting & discarding results...`); + // 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 = candidates.length > 0; + for (const c of candidates) { + const parentRes = parentResults.get(field) ?? []; + if (!this.#test(parentRes, input, c, depth)) allPass = false; + } - // Stop processing condition & empty the results - return { stop: true, void: true }; - } + if (!allPass) { + Logger.debug(`${gap}X Exiting & Discarding results...`); + return { values: parentResults, stop: true, void: true }; + } - // Append to local results - candidates.push(c); + for (const c of candidates) { + this.#appendResult(parentResults, c); } } } - - const existing = (parentResults.get(field) ?? []).map( - (r) => `${r.operator}${this.#txtCol(r.value, "m")}` - ); - Logger.debug( - ` ${gap}> ${this.#txtCol("Merging into", "b")}: [${existing.join( - ", " - )}]` - ); - - // Add the local results to the parent results - this.#sanitizeCandidates(candidates, depth).forEach((c) => - this.#appendResult(parentResults, c) - ); + //////////////////////////////////////////////////////////// } // Log the results + //////////////////////////////////////////////////////////// for (const [k, v] of parentResults.entries()) { const values = []; for (const c of v) { @@ -510,25 +494,31 @@ export class Introspector { } const msg = ` ${gap}${this.#txtCol("* Results", "m")} `; - Logger.debug(`${msg}${this.#txtCol(k, "g")}: ${values.join(", ")}`); + Logger.debug(`${msg}${this.#txtCol(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; + if (res.stop) return { values: parentResults, stop: res.stop, void: res.void }; - - if (res?.values) { - for (const constraints of res.values.values()) { - constraints.forEach((c) => this.#appendResult(parentResults, c)); - } - } } + ////////////////////////////////////////// return { values: parentResults, stop: false, void: false }; } @@ -550,7 +540,11 @@ export class Introspector { } /** - * todo test this and validate + * 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. @@ -565,24 +559,9 @@ export class Introspector { // Filter out results which do not match the field of the constraint candidates = candidates.filter((r) => r.field === item.field); - // Check if the input constraint matches the field of the item - const inputMatches = input.field === item.field; - // Add the input constraint to the results (if it also matches the field) - if (inputMatches) candidates.push({ ...input, operator: "==" }); - - // Prepare the log - const gap = " ".repeat(depth); - const col = this.#txtCol(item.field, "g"); - const val = this.#txtCol(item.value, "y"); - const pass = this.#txtCol("pass", "g"); - const fail = this.#txtCol("fail", "r"); - - const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; - - if (!candidates.length) { - Logger.debug(msg, `(${pass})`); - return true; + if (input.field === item.field) { + candidates.push({ ...input, operator: "==" }); } // Test that the constraint does not breach the results @@ -609,9 +588,12 @@ export class Introspector { * L NOT IN [501, 502] */ - // Must be equal to the value irrelevant of the operator - ops = ["==", ">=", "<="]; - if (ops.includes(operator) && value !== c.value) result = false; + // 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; @@ -671,6 +653,8 @@ export class Introspector { * NOT IN [501, 502] */ + // Always pass ["!=", ">", ">=", "not in"] + // Must be bigger than the value ops = ["==", "<="]; if (ops.includes(operator) && value <= c.value) result = false; @@ -678,10 +662,6 @@ export class Introspector { if ("<" === operator && Number(value) <= Number(c.value) + 2) result = false; - // // Always pass - // ops = ["!=", ">", ">=", "not in"]; - // if (ops.includes(operator)) result = true; - // One of the values in the item must match the candidate value if ("in" === operator) { if (!this.#asArray(value).some((val) => val > c.value)) @@ -700,6 +680,8 @@ export class Introspector { * IN [499, 500] */ + // Always pass ["!=", "<", "<=", "not in"] + // Must be smaller than the value ops = ["==", ">="]; if (ops.includes(operator) && value >= c.value) result = false; @@ -707,10 +689,6 @@ export class Introspector { if (">" === operator && Number(value) >= Number(c.value) - 2) result = false; - // // Always pass - // ops = ["!=", "<", "<=", "not in"]; - // if (ops.includes(operator)) result = true; - // One of the values in the item must match the candidate value if ("in" === operator) { if (!this.#asArray(value).some((val) => val < c.value)) @@ -729,6 +707,8 @@ export class Introspector { * L IN [500, 501] */ + // Always pass ["!=", ">=", ">", "not in"] + // Must be bigger than the value ops = ["==", "<="]; if (ops.includes(operator) && value < c.value) result = false; @@ -736,10 +716,6 @@ export class Introspector { if ("<" === operator && Number(value) < Number(c.value) + 1) result = false; - // Always pass - // ops = ["!=", ">=", ">", "not in"]; - // if ("!=" === operator) result = true; - // One of the values in the item must match the candidate value if ("in" === operator) { if (!this.#asArray(value).some((val) => val >= c.value)) @@ -757,6 +733,8 @@ export class Introspector { * L < any */ + // Always pass ["!=", "<=", "<", "not in"] + // Must be smaller than the value ops = ["==", ">="]; if (ops.includes(operator) && value > c.value) result = false; @@ -764,10 +742,6 @@ export class Introspector { if (">" === operator && Number(value) < Number(c.value) - 1) result = false; - // // Always pass - // ops = ["!=", "<=", "<", "not in"]; - // if ("!=" === operator) result = true; - // One of the values in the item must match the candidate value if ("in" === operator) { if (!this.#asArray(value).some((val) => val <= c.value)) @@ -832,8 +806,7 @@ export class Introspector { * NOT IN [500, 499] */ - // // Always pass - // if ("not in" === operator) result = true; + // Always pass ["not in"] let notInSubRes = false; let notInChecked = false; @@ -879,6 +852,14 @@ export class Introspector { } } + // Prepare the log + const gap = " ".repeat(depth); + const col = this.#txtCol(item.field, "g"); + const val = this.#txtCol(item.value, "y"); + const pass = this.#txtCol("pass", "g"); + const fail = this.#txtCol("fail", "r"); + + const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; Logger.debug(msg, `(${result ? pass : fail})`); // Return the result @@ -886,58 +867,110 @@ export class Introspector { } /** + * 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. * - * @param candidates The constraints to sanitize. + * 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. */ - #sanitizeCandidates(candidates: Constraint[], depth: number): Constraint[] { - // If the list less than 2 items, we can return it as is - if (candidates.length < 2) return candidates; + #sanitize(constraints: Constraint[], depth: number): Constraint[] { + // Flag to indicate if the list has been modified + let modified = false; - const gap = " ".repeat(depth); - const msg = ` ${gap}> ${this.#txtCol(`Sanitizing`, "b")}:`; + // Create a results list which we can modify + let results = JSON.parse(JSON.stringify(constraints)); - const values = []; - for (const c of candidates) { - values.push(`${c.operator}${this.#txtCol(c.value, "m")}`); - } + // 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; - // Flag to indicate if the list has been modified - let modified = false; + // 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; - // Search for candidates with <,>,<=,>= operators - for (const c of candidates) { - if (["<", ">", "<=", ">="].includes(c.operator)) { - const index = candidates.indexOf(c); - const val = c.value; - const op = c.operator; - - for (let i = 0; i < candidates.length; i++) { - if (i === index) continue; - - const item = candidates[i]; - if (item.operator === "==") { - if (["<=", ">="].includes(op)) { - delete candidates[i]; - modified = true; - } + if (">=" === op) { + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, results); + modified = true; + break; + } - if ("<" === op && item.value < val) { - delete candidates[i]; - modified = true; - } + 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); + modified = true; + break; + } - if (">" === op && item.value > val) { - delete candidates[i]; - modified = true; - } + 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); + modified = true; + break; + } + } + + if ("<=" === op) { + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, results); + modified = true; + break; + } + + 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); + modified = true; + break; + } + + 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); + modified = true; + break; + } + } + + if (">" === op) { + if ("==" === c.operator && c.value > val) { + results = this.#removeItem(c, results); + modified = true; + break; + } + } + + if ("<" === op) { + if ("==" === c.operator && c.value < val) { + results = this.#removeItem(c, results); + modified = true; + break; } } } } + const gap = " ".repeat(depth); + const msg = ` ${gap}${this.#txtCol(`* Sanitized`, "b")}`; + const values = results.map( + (c: Constraint) => `${c.operator}${this.#txtCol(c.value, "m")}` + ); + !modified && Logger.debug(`${msg} [${values.join(", ")}]`); - return modified ? this.#sanitizeCandidates(candidates, depth) : candidates; + return modified ? this.#sanitize(results, depth) : results; } /** @@ -994,6 +1027,17 @@ export class Introspector { return this.#stripNullProps(clone); } + /** + * 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. + */ + #removeItem(needle: any, haystack: any[]): any { + return haystack.filter( + (r: any) => JSON.stringify(r) !== JSON.stringify(needle) + ); + } + /** * Checks if a condition has a constraint with the provided field. * @param field The field to check for. diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index e765b0e..56530f3 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -1,221 +1,109 @@ -import { valid2Json } from "./rulesets/valid2.json"; -import { valid3Json } from "./rulesets/valid3.json"; -import { valid4Json } from "./rulesets/valid4.json"; -import { valid6Json } from "./rulesets/valid6.json"; -import { valid7Json } from "./rulesets/valid7.json"; -import { valid8Json } from "./rulesets/valid8.json"; import { valid9Json } from "./rulesets/valid9.json"; +import { valid8Json } from "./rulesets/valid8.json"; +import { valid7Json } from "./rulesets/valid7.json"; +import { valid6Json } from "./rulesets/valid6.json"; +import { valid4Json } from "./rulesets/valid4.json"; +import { valid3Json } from "./rulesets/valid3.json"; import { invalid1Json } from "./rulesets/invalid1.json"; -import { subRulesValid2Json } from "./rulesets/sub-rules-valid2.json"; -import { RuleError, RulePilot, RuleTypeError } from "../src"; +import { RuleError, RulePilot } 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: "<" }, + ], + }, }); - 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: 199 }; + expect(RulePilot.introspect(valid3Json, con, sub)).toEqual({ + 3: { Monetization: [{ value: "Real", 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 = ["Monetization"]; + con = { field: "Leverage", value: 200 }; + expect(RulePilot.introspect(valid3Json, con, sub)).toEqual({}); - 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 = ["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(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 = ["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(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 = ["IsUnder18"]; + con = { field: "Category", value: "Islamic" }; + expect(RulePilot.introspect(valid6Json, con, sub)).toEqual({}); - expect(RulePilot.introspect(subRulesValid2Json)).toEqual({ - results: [ - { - result: 3, - options: [{ Leverage: [1000, 500] }, { Category: "Demo" }], - }, - { - result: 4, - options: [{ Category: "Islamic" }], - }, - { - result: 15, - options: [ - { - Leverage: [{ operator: ">", value: 500 }, 1000, 500], // the gt 500 is wrong here - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], - CountryIso: ["GB", "FI"], // incorrect (should not exist) - Monetization: "Real", // incorrect (should not exist) - }, - { - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], - Leverage: [], - }, - ], - }, - { - result: 12, - options: [ - { - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], - CountryIso: ["GB", "FI"], - Leverage: [{ operator: ">", value: 500 }, 1000, 500], - Monetization: "Real", - }, - { - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], - Leverage: [], - }, - ], - }, - { - result: 13, - options: [ - { - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910], - CountryIso: ["GB", "FI"], - Leverage: [{ operator: ">", value: 500 }, 1000, 500], - Monetization: "Real", - }, - { - Category: [{ operator: ">=", value: 1000 }, 22, 900, 910, "Demo"], - Leverage: [], - }, + 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: ">=" }, ], }, - ], - default: 2, + }); + + 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" }] }, + }); + + sub = ["totalCheckoutPrice"]; + con = { field: "country", value: "SE" }; + expect(RulePilot.introspect(valid9Json, con, sub)).toEqual({}); }); }); From ddf08a5b89de463952a149be4b7c3fd4b64b10d0 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 17:59:28 +0100 Subject: [PATCH 11/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 61 +++++++++++------------------------- src/services/logger.ts | 23 ++++++++++++++ 2 files changed, 41 insertions(+), 43 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index d1dfb20..e8cf29c 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -70,8 +70,8 @@ export class Introspector { conditions.push({ all: [rule.parent, rule.subRule], result }); }); - console.log("subRules", JSON.stringify(subRules)); - console.log("conditions", JSON.stringify(conditions)); + // 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. @@ -101,7 +101,6 @@ export class Introspector { } } - console.log("Results", JSON.stringify(results)); return results; } @@ -355,7 +354,6 @@ export class Introspector { } /** - * todo test this and validate * Extracts all the possible combinations of criteria from the condition which are * self-consistent to the condition passing. * @param condition The condition to introspect. @@ -392,8 +390,8 @@ export class Introspector { const gap = " ".repeat(depth); const msg = 0 === depth - ? `\nIntrospecting "${this.#txtBold(type)}" condition` - : `${gap}--> "${this.#txtBold(type)}" condition`; + ? `\nIntrospecting "${Logger.bold(type)}" condition` + : `${gap}--> "${Logger.bold(type)}" condition`; Logger.debug(msg); // Iterate over all grouped constraints @@ -406,10 +404,10 @@ export class Introspector { for (const c of constraints) { // Append to local results if ("any" === type) { - const col = this.#txtCol(c.field, "g"); - const val = this.#txtCol(c.value, "y"); + 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, `(${this.#txtCol("pass", "g")})`); + Logger.debug(msg, `(${Logger.color("pass", "g")})`); candidates.push(c); } @@ -490,11 +488,11 @@ export class Introspector { for (const [k, v] of parentResults.entries()) { const values = []; for (const c of v) { - values.push(`${c.operator}${this.#txtCol(c.value, "y")}`); + values.push(`${c.operator}${Logger.color(c.value, "y")}`); } - const msg = ` ${gap}${this.#txtCol("* Results", "m")} `; - Logger.debug(`${msg}${this.#txtCol(k, "g")}: [${values.join(", ")}]`); + const msg = ` ${gap}${Logger.color("* Results", "m")} `; + Logger.debug(`${msg}${Logger.color(k, "g")}: [${values.join(", ")}]`); } //////////////////////////////////////////////////////////// @@ -541,7 +539,7 @@ export class Introspector { /** * 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 + * 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 @@ -854,10 +852,10 @@ export class Introspector { // Prepare the log const gap = " ".repeat(depth); - const col = this.#txtCol(item.field, "g"); - const val = this.#txtCol(item.value, "y"); - const pass = this.#txtCol("pass", "g"); - const fail = this.#txtCol("fail", "r"); + 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"); const msg = ` ${gap}> Testing '${col}'${item.operator}'${val}'`; Logger.debug(msg, `(${result ? pass : fail})`); @@ -964,38 +962,15 @@ export class Introspector { } const gap = " ".repeat(depth); - const msg = ` ${gap}${this.#txtCol(`* Sanitized`, "b")}`; + const msg = ` ${gap}${Logger.color(`* Sanitized`, "b")}`; const values = results.map( - (c: Constraint) => `${c.operator}${this.#txtCol(c.value, "m")}` + (c: Constraint) => `${c.operator}${Logger.color(c.value, "m")}` ); !modified && Logger.debug(`${msg} [${values.join(", ")}]`); return modified ? this.#sanitize(results, depth) : results; } - /** - * Formats text with color. - * @param text The text to colorize. - * @param color The color to apply. - */ - #txtCol(text: any, 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. - */ - #txtBold(text: any): string { - return `\x1b[1m${text}\x1b[0m`; - } - /** * Remove the provided sub-rule needle from the haystack condition * @param node The node to remove. @@ -1032,7 +1007,7 @@ export class Introspector { * @param needle The item to find and remove. * @param haystack The list to search in and remove from. */ - #removeItem(needle: any, haystack: any[]): any { + #removeItem(needle: any, haystack: R[]): R[] { return haystack.filter( (r: any) => JSON.stringify(r) !== JSON.stringify(needle) ); 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`; + } } From a1b7efa5aa6f22d2c58360d1861156ef90150d6a Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 19:31:54 +0100 Subject: [PATCH 12/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 85 +++++------- test/introspector.spec.ts | 246 ++++++++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 51 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index e8cf29c..f004106 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -413,7 +413,7 @@ export class Introspector { } if ("all" === type) { - if (!this.#test(candidates, input, c, depth)) { + if (!this.test(candidates, input, c, depth)) { candidates = []; Logger.debug(` ${gap}- Clearing all local`); break; @@ -450,7 +450,7 @@ export class Introspector { const valid = []; for (const c of candidates) { const parentRes = parentResults.get(field) ?? []; - if (this.#test(parentRes, input, c, depth)) valid.push(c); + if (this.test(parentRes, input, c, depth)) valid.push(c); } if (!valid.length) { @@ -467,7 +467,7 @@ export class Introspector { let allPass = candidates.length > 0; for (const c of candidates) { const parentRes = parentResults.get(field) ?? []; - if (!this.#test(parentRes, input, c, depth)) allPass = false; + if (!this.test(parentRes, input, c, depth)) allPass = false; } if (!allPass) { @@ -499,7 +499,7 @@ export class Introspector { // Sanitize the results //////////////////////////////////////////////////////////// for (const [field, constraints] of parentResults.entries()) { - parentResults.set(field, this.#sanitize(constraints, depth)); + parentResults.set(field, this.sanitize(constraints, depth)); } //////////////////////////////////////////////////////////// @@ -548,7 +548,7 @@ export class Introspector { * @param item The constraint item to test against the candidates. * @param depth The current recursion depth. */ - #test( + protected test( candidates: Constraint[], input: Omit, item: Constraint, @@ -737,7 +737,7 @@ export class Introspector { ops = ["==", ">="]; if (ops.includes(operator) && value > c.value) result = false; - if (">" === operator && Number(value) < Number(c.value) - 1) + if (">" === operator && Number(value) > Number(c.value) - 1) result = false; // One of the values in the item must match the candidate value @@ -752,49 +752,50 @@ export class Introspector { * IN [500, 502] * NOT IN [499, 500] */ - let inSubRes = false; - let inChecked = false; + result = false; + // At least 1 item from list must pass // For each item run the same checks as for the '==' operator - // If none of the items pass, then fail for (const subVal of this.#asArray(c.value)) { - // Must be equal to the value irrelevant of the operator - ops = ["==", ">=", "<="]; - if (ops.includes(operator) && value !== subVal) { - inSubRes = true; - inChecked = true; + // 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) { - inSubRes = true; - inChecked = true; + result = true; } if (">" === operator && value < subVal) { - inSubRes = true; - inChecked = true; + result = true; } // Item value cannot be equal to constraint value if ("!=" === operator && value !== subVal) { - inSubRes = true; - inChecked = true; + result = true; } } - if (inChecked && !inSubRes) result = false; + const inValues = this.#asArray(c.value); // One of the values in the item must match any candidate values - const inValues = this.#asArray(c.value); if ("in" === operator) { - if (!this.#asArray(value).some((val) => inValues.includes(val))) - result = false; + 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 = false; + if (this.#asArray(value).some((val) => !inValues.includes(val))) + result = true; } break; case "not in": @@ -803,36 +804,22 @@ export class Introspector { * IN [499, 501] * NOT IN [500, 499] */ + result = true; - // Always pass ["not in"] - - let notInSubRes = false; - let notInChecked = false; - - // For each item run the same checks as for the '!=' operator - // If none of the items pass, then fail + // All items from list must pass for (const subVal of this.#asArray(c.value)) { // Must be different - if ("==" === operator && value !== subVal) { - notInSubRes = true; - notInChecked = true; - } - - // Always pass - ops = ["!=", ">", ">=", "<", "<=", "not in"]; - if (ops.includes(operator)) { - notInSubRes = true; - notInChecked = true; + if ("==" === operator && value === subVal) { + result = false; } } - if (notInChecked && !notInSubRes) result = false; + const notInValues = this.#asArray(c.value); // One of the values in the item must NOT match any candidate values - const nInValues = this.#asArray(c.value); if ("in" === operator) { - if (!this.#asArray(value).some((val) => !nInValues.includes(val))) - result = false; + if (this.#asArray(value).some((val) => !notInValues.includes(val))) + result = true; } break; // case "contains": @@ -874,7 +861,7 @@ export class Introspector { * @param constraints The constraints to sanitize. * @param depth The current recursion depth. */ - #sanitize(constraints: Constraint[], depth: number): Constraint[] { + protected sanitize(constraints: Constraint[], depth: number): Constraint[] { // Flag to indicate if the list has been modified let modified = false; @@ -968,7 +955,7 @@ export class Introspector { ); !modified && Logger.debug(`${msg} [${values.join(", ")}]`); - return modified ? this.#sanitize(results, depth) : results; + return modified ? this.sanitize(results, depth) : results; } /** diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index 56530f3..5815ab6 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -6,7 +6,8 @@ import { valid4Json } from "./rulesets/valid4.json"; import { valid3Json } from "./rulesets/valid3.json"; import { invalid1Json } from "./rulesets/invalid1.json"; -import { RuleError, RulePilot } from "../src"; +import { Introspector } from "../src/services"; +import { RuleError, RulePilot, Constraint } from "../src"; describe("RulePilot introspector correctly", () => { it("Detects invalid rules", async () => { @@ -97,7 +98,7 @@ describe("RulePilot introspector correctly", () => { expect(RulePilot.introspect(valid7Json, con, sub)).toEqual({}); sub = ["OtherType"]; - con = { field: "Leverage", value: 999 }; + con = { field: "Lev", value: 999 }; expect(RulePilot.introspect(valid8Json, con, sub)).toEqual({ 3: { OtherType: [{ value: ["Live", "Fun"], operator: "in" }] }, }); @@ -106,4 +107,245 @@ describe("RulePilot introspector correctly", () => { con = { field: "country", value: "SE" }; expect(RulePilot.introspect(valid9Json, con, sub)).toEqual({}); }); + + 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); + + // + + 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: 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: 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); + } +} From 661e0bdd2de2b77644e09f22acfd7008030273fe Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 20:43:52 +0100 Subject: [PATCH 13/18] feat(core): Work on introspection v2 --- src/services/introspector.ts | 111 +++++++++++++++++++++++++++-------- test/introspector.spec.ts | 86 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 25 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index f004106..b46c77e 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -862,9 +862,6 @@ export class Introspector { * @param depth The current recursion depth. */ protected sanitize(constraints: Constraint[], depth: number): Constraint[] { - // Flag to indicate if the list has been modified - let modified = false; - // Create a results list which we can modify let results = JSON.parse(JSON.stringify(constraints)); @@ -880,9 +877,7 @@ export class Introspector { if (">=" === op) { if ("==" === c.operator && c.value === val) { - results = this.#removeItem(c, results); - modified = true; - break; + return this.sanitize(this.#removeItem(c, results), depth + 1); } if (">" === c.operator) { @@ -890,8 +885,8 @@ export class Introspector { if (c.value >= val) results = this.#removeItem(c, results); // >=500, >499- (remove >=500) if (c.value < val) results = this.#removeItem(sub, results); - modified = true; - break; + + return this.sanitize(results, depth + 1); } if (">=" === c.operator) { @@ -899,16 +894,14 @@ export class Introspector { if (c.value >= val) results = this.#removeItem(c, results); // >=500, >=499- (remove >=500) if (c.value < val) results = this.#removeItem(sub, results); - modified = true; - break; + + return this.sanitize(results, depth + 1); } } if ("<=" === op) { if ("==" === c.operator && c.value === val) { - results = this.#removeItem(c, results); - modified = true; - break; + return this.sanitize(this.#removeItem(c, results), depth + 1); } if ("<" === c.operator) { @@ -916,8 +909,8 @@ export class Introspector { if (c.value <= val) results = this.#removeItem(c, results); // <=500, <501+ (remove >=500) if (c.value > val) results = this.#removeItem(sub, results); - modified = true; - break; + + return this.sanitize(results, depth + 1); } if ("<=" === c.operator) { @@ -925,27 +918,95 @@ export class Introspector { if (c.value <= val) results = this.#removeItem(c, results); // <=500, <501+ (remove >=500) if (c.value > val) results = this.#removeItem(sub, results); - modified = true; - break; + + return this.sanitize(results, depth + 1); } } if (">" === op) { if ("==" === c.operator && c.value > val) { - results = this.#removeItem(c, results); - modified = true; - break; + return this.sanitize(this.#removeItem(c, results), depth + 1); + } + + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, operator: ">=" }); + + return this.sanitize(results, depth + 1); + } + + if (">" === c.operator && c.value >= val) { + return this.sanitize(this.#removeItem(c, results), depth + 1); } } if ("<" === op) { if ("==" === c.operator && c.value < val) { - results = this.#removeItem(c, results); - modified = true; - break; + return this.sanitize(this.#removeItem(c, results), depth + 1); + } + + if ("==" === c.operator && c.value === val) { + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, operator: "<=" }); + + return this.sanitize(results, depth + 1); + } + + if ("<" === c.operator && c.value <= val) { + return this.sanitize(this.#removeItem(c, results), depth + 1); + } + } + + 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), + ]); + + results = this.#removeItem(c, this.#removeItem(sub, results)); + results.push({ ...sub, value: [...set].sort() }); + + return this.sanitize(results, depth + 1); + } + } + + 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 + 1); } } } + + // 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 + 1); + } + } + + // 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 this.sanitize(results, depth + 1); + } + } } const gap = " ".repeat(depth); @@ -954,8 +1015,8 @@ export class Introspector { (c: Constraint) => `${c.operator}${Logger.color(c.value, "m")}` ); - !modified && Logger.debug(`${msg} [${values.join(", ")}]`); - return modified ? this.sanitize(results, depth) : results; + Logger.debug(`${msg} [${values.join(", ")}]`); + return results; } /** diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index 5815ab6..df23898 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -108,6 +108,80 @@ describe("RulePilot introspector correctly", () => { expect(RulePilot.introspect(valid9Json, con, sub)).toEqual({}); }); + 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 }; @@ -253,6 +327,18 @@ describe("RulePilot introspector correctly", () => { 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" }]; From ac0956bc15aee5fe59573c686aa9d440d6ee6543 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 20:53:57 +0100 Subject: [PATCH 14/18] feat(core): Work on introspection v2 --- .github/workflows/jest.yaml | 21 +++++++++++++------ .../npm-publish-github-packages.yaml | 2 +- test/introspector.spec.ts | 10 ++++----- 3 files changed, 21 insertions(+), 12 deletions(-) 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/test/introspector.spec.ts b/test/introspector.spec.ts index df23898..ae043d0 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -1,9 +1,9 @@ -import { valid9Json } from "./rulesets/valid9.json"; -import { valid8Json } from "./rulesets/valid8.json"; -import { valid7Json } from "./rulesets/valid7.json"; -import { valid6Json } from "./rulesets/valid6.json"; -import { valid4Json } from "./rulesets/valid4.json"; import { valid3Json } from "./rulesets/valid3.json"; +import { valid4Json } from "./rulesets/valid4.json"; +import { valid6Json } from "./rulesets/valid6.json"; +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 { Introspector } from "../src/services"; From e87a55dba7d4cbc0c9b9cb109665606b95bb20f1 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Wed, 13 Nov 2024 21:19:33 +0100 Subject: [PATCH 15/18] feat(core): Work on introspection v2 --- test/engine.spec.ts | 2 +- test/rulesets/sub-rules-valid2.json.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/engine.spec.ts b/test/engine.spec.ts index 1c1e08b..3b96f9b 100644 --- a/test/engine.spec.ts +++ b/test/engine.spec.ts @@ -299,7 +299,7 @@ describe("RulePilot engine correctly", () => { expect( await RulePilot.evaluate(subRulesValid2Json, { Category: "Demo", - Leverage: 600, + Leverage: 500, CountryIso: "GB", Monetization: "Real", }) diff --git a/test/rulesets/sub-rules-valid2.json.ts b/test/rulesets/sub-rules-valid2.json.ts index 6f011a1..d0163ed 100644 --- a/test/rulesets/sub-rules-valid2.json.ts +++ b/test/rulesets/sub-rules-valid2.json.ts @@ -110,7 +110,7 @@ export const subRulesValid2Json: Rule = { { field: "Leverage", operator: ">", - value: 500, + value: 400, }, { field: "Monetization", From 6ec085b387de3b75705ace64679a027686270eed Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Thu, 14 Nov 2024 12:05:58 +0100 Subject: [PATCH 16/18] feat(core): Add docs --- src/services/introspector.ts | 72 ++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/src/services/introspector.ts b/src/services/introspector.ts index b46c77e..70b6615 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -415,7 +415,7 @@ export class Introspector { if ("all" === type) { if (!this.test(candidates, input, c, depth)) { candidates = []; - Logger.debug(` ${gap}- Clearing all local`); + Logger.debug(` ${gap}- Clearing local candidates...`); break; } @@ -454,7 +454,9 @@ export class Introspector { } if (!valid.length) { - Logger.debug(`${gap}X Exiting & Discarding results...`); + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); return { values: parentResults, stop: true, void: true }; } @@ -471,7 +473,9 @@ export class Introspector { } if (!allPass) { - Logger.debug(`${gap}X Exiting & Discarding results...`); + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); return { values: parentResults, stop: true, void: true }; } @@ -491,7 +495,7 @@ export class Introspector { values.push(`${c.operator}${Logger.color(c.value, "y")}`); } - const msg = ` ${gap}${Logger.color("* Results", "m")} `; + const msg = ` ${gap}${Logger.color("* Candidates", "m")} `; Logger.debug(`${msg}${Logger.color(k, "g")}: [${values.join(", ")}]`); } //////////////////////////////////////////////////////////// @@ -877,7 +881,7 @@ export class Introspector { if (">=" === op) { if ("==" === c.operator && c.value === val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } if (">" === c.operator) { @@ -886,7 +890,7 @@ export class Introspector { // >=500, >499- (remove >=500) if (c.value < val) results = this.#removeItem(sub, results); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } if (">=" === c.operator) { @@ -895,13 +899,13 @@ export class Introspector { // >=500, >=499- (remove >=500) if (c.value < val) results = this.#removeItem(sub, results); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } if ("<=" === op) { if ("==" === c.operator && c.value === val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } if ("<" === c.operator) { @@ -910,7 +914,7 @@ export class Introspector { // <=500, <501+ (remove >=500) if (c.value > val) results = this.#removeItem(sub, results); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } if ("<=" === c.operator) { @@ -919,41 +923,41 @@ export class Introspector { // <=500, <501+ (remove >=500) if (c.value > val) results = this.#removeItem(sub, results); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } if (">" === op) { if ("==" === c.operator && c.value > val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } if ("==" === c.operator && c.value === val) { results = this.#removeItem(c, this.#removeItem(sub, results)); results.push({ ...sub, operator: ">=" }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } if (">" === c.operator && c.value >= val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } } if ("<" === op) { if ("==" === c.operator && c.value < val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } if ("==" === c.operator && c.value === val) { results = this.#removeItem(c, this.#removeItem(sub, results)); results.push({ ...sub, operator: "<=" }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } if ("<" === c.operator && c.value <= val) { - return this.sanitize(this.#removeItem(c, results), depth + 1); + return this.sanitize(this.#removeItem(c, results), depth); } } @@ -968,7 +972,7 @@ export class Introspector { results = this.#removeItem(c, this.#removeItem(sub, results)); results.push({ ...sub, value: [...set].sort() }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } @@ -983,7 +987,7 @@ export class Introspector { results = this.#removeItem(c, this.#removeItem(sub, results)); results.push({ ...sub, value: [...set].sort() }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } } @@ -994,7 +998,7 @@ export class Introspector { results = this.#removeItem(sub, results); results.push({ field: sub.field, operator: "==", value: val }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } @@ -1004,7 +1008,7 @@ export class Introspector { results = this.#removeItem(sub, results); results.push({ field: sub.field, operator: "!=", value: val }); - return this.sanitize(results, depth + 1); + return this.sanitize(results, depth); } } } @@ -1087,6 +1091,34 @@ export class Introspector { } /** + * 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 From ac83e90dc3aba712fa396dc5d5e11c3cf4636a50 Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Fri, 15 Nov 2024 12:11:42 +0100 Subject: [PATCH 17/18] feat(core): Add tests, update docs, fix bugs --- README.md | 61 ++++++++++++----------- src/services/introspector.ts | 94 ++++++++++++++++++++++++------------ test/introspector.spec.ts | 63 +++++++++++++++++++++++- 3 files changed, 159 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 3f3910e..94a36e0 100644 --- a/README.md +++ b/README.md @@ -585,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. -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. +For example, using introspection we can ask Rule Pilot the following question: + +> 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. @@ -629,39 +631,44 @@ const rule: 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. -// Intropect the rule -const introspection = RulePilot.introspect(rule); +```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. diff --git a/src/services/introspector.ts b/src/services/introspector.ts index 70b6615..af5e799 100644 --- a/src/services/introspector.ts +++ b/src/services/introspector.ts @@ -394,10 +394,16 @@ export class Introspector { : `${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 local results - let candidates: Constraint[] = []; + // Prepare the candidates for this field type + let candidates = results.get(field) ?? []; + + if (clearLocal) continue; // Test the constraints and prepare the local results //////////////////////////////////////////////////////////// @@ -415,27 +421,45 @@ export class Introspector { 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); } } - //////////////////////////////////////////////////////////// - // Merge the local results with the parent results - //////////////////////////////////////////////////////////// - if (null === parentType) { + // Store the updated candidates into the local results + results.set(field, candidates); + } + + if (clearLocal) results = new Map(); + + //////////////////////////////////////////////////////////// + + // 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) { + if ("any" === parentType) { + if ("any" === type) { + for (const [_, candidates] of results.entries()) { for (const c of candidates) this.#appendResult(parentResults, c); } + } - if ("all" === type) { + if ("all" === type) { + for (const [_, candidates] of results.entries()) { if (!candidates.length) { Logger.debug(`${gap}X Exiting...`); return { values: parentResults, stop: true, void: false }; @@ -444,48 +468,55 @@ export class Introspector { for (const c of candidates) this.#appendResult(parentResults, c); } } + } - if ("all" === parentType) { - if ("any" === type) { - const valid = []; + 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); } + } - if (!valid.length) { - Logger.debug( - `${gap}${Logger.color("Exiting & Discarding results...", "r")}` - ); - return { values: parentResults, stop: true, void: true }; - } - - for (const c of valid) this.#appendResult(parentResults, c); + if (!valid.length) { + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); + return { values: parentResults, stop: true, void: true }; } - 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 = candidates.length > 0; + for (const c of valid) this.#appendResult(parentResults, c); + } + + 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; + + 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 }; - } + if (!allPass) { + Logger.debug( + `${gap}${Logger.color("Exiting & Discarding results...", "r")}` + ); + return { values: parentResults, stop: true, void: true }; + } + for (const [_, candidates] of results.entries()) { for (const c of candidates) { this.#appendResult(parentResults, c); } } } - //////////////////////////////////////////////////////////// } + //////////////////////////////////////////////////////////// // Log the results //////////////////////////////////////////////////////////// @@ -822,6 +853,7 @@ export class Introspector { // 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; } diff --git a/test/introspector.spec.ts b/test/introspector.spec.ts index ae043d0..b1de7a8 100644 --- a/test/introspector.spec.ts +++ b/test/introspector.spec.ts @@ -5,6 +5,7 @@ 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 { Introspector } from "../src/services"; import { RuleError, RulePilot, Constraint } from "../src"; @@ -98,7 +99,7 @@ describe("RulePilot introspector correctly", () => { expect(RulePilot.introspect(valid7Json, con, sub)).toEqual({}); sub = ["OtherType"]; - con = { field: "Lev", value: 999 }; + con = { field: "Leverage", value: 999 }; expect(RulePilot.introspect(valid8Json, con, sub)).toEqual({ 3: { OtherType: [{ value: ["Live", "Fun"], operator: "in" }] }, }); @@ -106,6 +107,33 @@ describe("RulePilot introspector correctly", () => { 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 () => { @@ -352,6 +380,24 @@ describe("RulePilot introspector correctly", () => { 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: ">" }; @@ -399,6 +445,21 @@ describe("RulePilot introspector correctly", () => { 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: ">" }; From 988e9abe280351f4bf362dbdd793d708db42463c Mon Sep 17 00:00:00 2001 From: "andrew.borg" Date: Fri, 15 Nov 2024 14:41:37 +0100 Subject: [PATCH 18/18] feat(core): Update package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 374aec7..051b7e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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",