diff --git a/docs/manifest.json b/docs/manifest.json index d7f74d47995b6..2fc23eaefaa6f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1925,6 +1925,12 @@ "markdown_source": "../packages/router/README.md", "parent": "packages" }, + { + "title": "@wordpress/rule-parser", + "slug": "packages-rule-parser", + "markdown_source": "../packages/rule-parser/README.md", + "parent": "packages" + }, { "title": "@wordpress/scripts", "slug": "packages-scripts", diff --git a/package-lock.json b/package-lock.json index 5f393e700c289..5c989c73ae5ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "@wordpress/reusable-blocks": "file:packages/reusable-blocks", "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/router": "file:packages/router", + "@wordpress/rule-parser": "file:packages/rule-parser", "@wordpress/server-side-render": "file:packages/server-side-render", "@wordpress/shortcode": "file:packages/shortcode", "@wordpress/style-engine": "file:packages/style-engine", @@ -17198,6 +17199,10 @@ "resolved": "packages/router", "link": true }, + "node_modules/@wordpress/rule-parser": { + "resolved": "packages/rule-parser", + "link": true + }, "node_modules/@wordpress/scripts": { "resolved": "packages/scripts", "link": true @@ -54774,6 +54779,11 @@ "react": "^18.0.0" } }, + "packages/rule-parser": { + "name": "@wordpress/rule-parser", + "version": "1.0.0-prerelease", + "license": "GPL-2.0-or-later" + }, "packages/scripts": { "name": "@wordpress/scripts", "version": "29.0.0", @@ -68961,6 +68971,9 @@ "history": "^5.3.0" } }, + "@wordpress/rule-parser": { + "version": "file:packages/rule-parser" + }, "@wordpress/scripts": { "version": "file:packages/scripts", "requires": { diff --git a/package.json b/package.json index 9236dbfb47ade..85cb92f4407bc 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@wordpress/reusable-blocks": "file:packages/reusable-blocks", "@wordpress/rich-text": "file:packages/rich-text", "@wordpress/router": "file:packages/router", + "@wordpress/rule-parser": "file:packages/rule-parser", "@wordpress/server-side-render": "file:packages/server-side-render", "@wordpress/shortcode": "file:packages/shortcode", "@wordpress/style-engine": "file:packages/style-engine", diff --git a/packages/rule-parser/.npmrc b/packages/rule-parser/.npmrc new file mode 100644 index 0000000000000..9cf9495031ecc --- /dev/null +++ b/packages/rule-parser/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/rule-parser/CHANGELOG.md b/packages/rule-parser/CHANGELOG.md new file mode 100644 index 0000000000000..6349399989da3 --- /dev/null +++ b/packages/rule-parser/CHANGELOG.md @@ -0,0 +1,5 @@ + + +## Unreleased + +Initial release. \ No newline at end of file diff --git a/packages/rule-parser/README.md b/packages/rule-parser/README.md new file mode 100644 index 0000000000000..9831a5ee7d302 --- /dev/null +++ b/packages/rule-parser/README.md @@ -0,0 +1,180 @@ +# Rule Parser + +This package provides a rule parser to evaluate logical rules into a boolean value. + +## Installation + +Install the module + +```bash +npm install @wordpress/rule-parser --save +``` + +## API + +> [!NOTE] +> This package is only meant to be used by implementors, not direct developers. implementors are then responsible for collecting rules from developers or users, either via UI (like Blocks Visibility), or code (like DataForms), evalute the rules against their provided context, and use the outcome however they see fit. + +The package accepts rules in the shape of `[ source, operator, target ]` and can be infinitely nested on a variation of combinators, that resemble `||` (OR, ANY) or `&&` (AND, ALL). + +### Examples of rules + +1. Simple rule + +```js +const rule = [ [ 'user.role', 'is', 'editor' ] ]; +``` + +2. Array of rules + +```js +const rules = [ + [ 'user.role', 'is', 'editor' ], + [ 'post.categories', 'contains', 'tutorials' ], +]; +``` + +3. Array of rules with explicit combinator. If no combinator is set, `ALL` will be used. + +```js +const rules = [ + 'ALL', + [ + [ 'user.role', 'is', 'editor' ], + [ 'post.categories', 'contains', 'tutorials' ], + ], +]; +``` + +4. Nested arrays + +```JS +const rules = [ + 'ANY', + [ + [ 'user.role', 'is', 'editor' ], + [ 'post.categories', 'contains', 'tutorials' ], + [ + 'ALL', + [ + [ 'user.id', 'in', [ 1, 2, 3 ] ], + [ 'post.blocks', 'not contains', 'core/embed' ] + ] + ] + ] +]; +``` + +### Anatomy of a rule + +A rule is made of 3 values, a source, an operator, and a target. + +- **Source**: which start as a rawSource (a string) that will be evaluated to a primitive (string, number, boolean) or an array of primitives, evaluation is done by using the context that the implementor provides. +- **Operator**: a function that compares an evaluated source to a target value, this package ships with 10 operator, and provides the ability to alias them as well as providing new operators. +- **Target**: a primitive (string, number, boolean) or an array of primitives (all the same type), targets are static at code/UI level, and should be considered static regardless of session and place (PHP or JS). + +Along of combinators, an array of rules can be transformed to a single boolean. + +### Usage + +```js +import { parser } from "@wordpress/rule-parser"; + +const context = { + 'user.id': 1. + 'user.role': 'admin', + 'post.categories': [ 'tutorials' ], + 'post.blocks': [ 'core/paragraph', 'core/heading', 'woocommerce/checkout' ] +}; + +const rules = [ + 'ANY', + [ + [ 'user.role', 'is', 'editor' ], + [ 'post.categories', 'contains', 'tutorials' ], + [ + 'ALL', + [ + [ 'user.id', 'in', [ 1, 2, 3 ] ], + [ 'post.blocks', 'not contains', 'core/embed' ] + ] + ] + ] +]; + +const result = parser( rules, context ); + +console.log( result ); // true +``` + +### Operators + +The package ships with 10 operators, and provides the ability to alias them as well as providing new operators. + +Here's a table of the available operators, their descriptions, examples, and aliases: + +| Operator | Description | Example | Aliases | +| -------------- | ------------------------------------------------------------------------- | ----------------------------------------------- | ----------- | +| `is` | Checks if the source is loosly equal to the target | `['user.role', 'is', 'admin']` | `=` | +| `is not` | Checks if the source is not loosly equal to the target | `['user.role', 'is not', 'subscriber']` | `!=` | +| `contains` | Checks if the source array includes the target value | `['post.categories', 'contains', 'tutorials']` | N/A | +| `not contains` | Checks if the source array does not include the target value | `['post.blocks', 'not contains', 'core/embed']` | `!contains` | +| `in` | Checks if the source value is in the target array | `['user.id', 'in', [1, 2, 3]]` | N/A | +| `not in` | Checks if the source value is not in the target array | `['user.id', 'not in', [4, 5, 6]]` | `!in` | +| `greater than` | Checks if the source number is greater than the target number | `['post.comments', 'greater than', 10]` | `>` | +| `less than` | Checks if the source number is less than the target number | `['post.views', 'less than', 100]` | `<` | +| `gte` | Checks if the source number is greater than or equal to the target number | `['post.comments', 'gte', 10]` | `>=` | +| `lte` | Checks if the source number is less than or equal to the target number | `['post.views', 'lte', 100]` | `<=` | + +In addition to the built-in operators, the package includes a registry system for custom operators and aliases. This allows implementors to extend or customize the rule system as needed. + +```js +import { parser, registry } from '@wordpress/rule-parser'; + +registry.register( 'between', ( source, target, rule ) => { + if ( typeof source !== 'number' ) { + throw new TypeError( 'Source must be of number' ); + } + + if ( ! Array.isArray( target ) || target.length !== 2 ) { + throw new TypeError( 'Target must be an array of 2 numbers.' ); + } + + const [ min, max ] = target; + + if ( typeof min !== 'number' || typeof max !== 'number' ) { + throw new TypeError( 'Target must be an array of 2 numbers.' ); + } + + if ( min >= max ) { + throw new TypeError( 'Min must be less than max.' ); + } + + return source >= min && source <= max; +} ); + +registry.alias( '<>', 'between' ); + +parser( [ [ 'cart.totals', '<>', [ 50, 100 ] ], { 'cart.totals': 75 } ); // true. +``` + +### Types + +The package ships with a set of types that are used to define the structure of a rule, and the type of the values that are used in a rule. + +```ts +import type { RawRule, Rules, EvaluatorFunction } from '@wordpress/rule-parser'; + +const rule: RawRule = [ 'user.id', 'is', 1 ]; +const rules: Rules< RawRule > = [ 'ANY', [ rule ] ]; +const customOperator: EvaluatorFunction = ( source, target, rule ) => + source === target; +``` + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/rule-parser/package.json b/packages/rule-parser/package.json new file mode 100644 index 0000000000000..a1530afa56ba5 --- /dev/null +++ b/packages/rule-parser/package.json @@ -0,0 +1,27 @@ +{ + "name": "@wordpress/rule-parser", + "version": "1.0.0-prerelease", + "description": "A rule parser to evaluate logical rules into a boolean value.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/rule-parser/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/rule-parser" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "sideEffects": "src/evaluators/registry.ts", + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/rule-parser/src/constants.ts b/packages/rule-parser/src/constants.ts new file mode 100644 index 0000000000000..4683d814f64f0 --- /dev/null +++ b/packages/rule-parser/src/constants.ts @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import type { Combinator } from './types'; + +export const combinators: Combinator[] = [ 'ANY', 'ALL' ]; diff --git a/packages/rule-parser/src/evaluators/equation.ts b/packages/rule-parser/src/evaluators/equation.ts new file mode 100644 index 0000000000000..df755dc7ba9c2 --- /dev/null +++ b/packages/rule-parser/src/evaluators/equation.ts @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import type { Source, Target, Rule } from '../types'; +import { stringifyRule } from '../helpers'; + +/** + * Evaluates a rule with the 'is' operator. + * + * @param {Source} source The source value. + * @param {Target} target The target value. + * @param {Rule} rule The rule to evaluate. + * @return {boolean} The result of the evaluation. + */ +export function evaluateIs( + source: Source, + target: Target, + rule: Rule +): boolean { + if ( + ( Array.isArray( source ) && ! Array.isArray( target ) ) || + ( ! Array.isArray( source ) && Array.isArray( target ) ) + ) { + throw new TypeError( + `Rule ${ stringifyRule( + rule + ) } source and target must be both primitives or arrays for operator '${ + rule[ 1 ] + }'` + ); + } + + if ( Array.isArray( source ) && Array.isArray( target ) ) { + return source.every( ( item ) => target.includes( item ) ); + } + + // Equation supports loose comparison. + // eslint-disable-next-line eqeqeq + return source == target; +} diff --git a/packages/rule-parser/src/evaluators/inclusion.ts b/packages/rule-parser/src/evaluators/inclusion.ts new file mode 100644 index 0000000000000..061e1ace20715 --- /dev/null +++ b/packages/rule-parser/src/evaluators/inclusion.ts @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import type { Source, Target, Rule } from '../types'; +import { valueType, stringifyRule } from '../helpers'; + +/** + * Evaluates a rule with the 'in' operator. + * + * @param {Source} source The source value. + * @param {Target} target The target value. + * @param {Rule} rule The rule to evaluate. + * @return {boolean} The result of the evaluation. + */ +export function evaluateInclusion( + source: Source, + target: Target, + rule: Rule +): boolean { + if ( valueType( source ) !== valueType( target ) ) { + throw new TypeError( + `Rule ${ stringifyRule( + rule + ) } source and target must be the same type for operator '${ + rule[ 1 ] + }'` + ); + } + + // IN operator only supports a string target if the source is also a string. + if ( ! Array.isArray( target ) ) { + if ( typeof target === 'string' && typeof source === 'string' ) { + return target.includes( source ); + } + throw new TypeError( + `Rule ${ stringifyRule( + rule + ) } target must be an array for operator '${ + rule[ 1 ] + }' or both need to be a string` + ); + } + + if ( Array.isArray( source ) ) { + return source.every( ( item ) => target.includes( item ) ); + } + + // Includes kept returning a TS error. + return target.includes( source ); +} diff --git a/packages/rule-parser/src/evaluators/index.ts b/packages/rule-parser/src/evaluators/index.ts new file mode 100644 index 0000000000000..6c8f7f5b98b29 --- /dev/null +++ b/packages/rule-parser/src/evaluators/index.ts @@ -0,0 +1 @@ +export { registry } from './registry'; diff --git a/packages/rule-parser/src/evaluators/numeric-compare.ts b/packages/rule-parser/src/evaluators/numeric-compare.ts new file mode 100644 index 0000000000000..ed0b69774a72b --- /dev/null +++ b/packages/rule-parser/src/evaluators/numeric-compare.ts @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import type { Source, Target, Rule } from '../types'; +import { stringifyRule } from '../helpers'; + +/** + * Evaluates a rule with the 'less than' operator. + * + * @param {Source} source The source value. + * @param {Target} target The target value. + * @param {Rule} rule The rule to evaluate. + * @param {boolean} inclusive Whether to use inclusive comparison. + * @return {boolean} The result of the evaluation. + */ +export function evaluateNumericCompare( + source: Source, + target: Target, + rule: Rule, + inclusive: boolean = false +): boolean { + if ( typeof source === 'string' ) { + source = parseFloat( source ); + } + + if ( typeof target === 'string' ) { + target = parseFloat( target ); + } + + if ( typeof source !== 'number' || typeof target !== 'number' ) { + throw new TypeError( + `Rule ${ stringifyRule( + rule + ) } source and target must be numbers for operator '${ rule[ 1 ] }'` + ); + } + + if ( inclusive ) { + return source <= target; + } + + return source < target; +} diff --git a/packages/rule-parser/src/evaluators/registry.ts b/packages/rule-parser/src/evaluators/registry.ts new file mode 100644 index 0000000000000..1c26a3b61a77b --- /dev/null +++ b/packages/rule-parser/src/evaluators/registry.ts @@ -0,0 +1,103 @@ +/** + * Internal dependencies + */ +import type { + EvaluatorFunction, + Source, + Target, + Rule, + Operator, +} from '../types'; +import { evaluateIs } from './equation'; +import { evaluateInclusion } from './inclusion'; +import { evaluateNumericCompare } from './numeric-compare'; + +function createRegistry() { + const functions: Map< Operator, EvaluatorFunction > = new Map(); + + const aliases: Record< string, Operator > = {}; + function register( key: Operator, func: EvaluatorFunction ): void { + functions.set( key, func ); + } + + function alias( aliasKey: string, existingKey: Operator ): void { + aliases[ aliasKey ] = existingKey; + } + + function call( + key: string, + ...args: Parameters< EvaluatorFunction > + ): ReturnType< EvaluatorFunction > { + let func = functions.get( key ); + if ( ! func ) { + const aliasedKey = aliases[ key ]; + if ( aliasedKey ) { + func = functions.get( aliasedKey ); + } + if ( ! func ) { + throw new Error( + `No such evaluator with key "${ key }" exists.` + ); + } + } + return func( ...args ); + } + + function has( key: Operator ): boolean { + return functions.has( key ); + } + + function getOperators(): Operator[] { + return Array.from( functions.keys() ); + } + + return { register, call, has, alias, getOperators }; +} + +const registry = createRegistry(); + +registry.register( 'is', evaluateIs ); +registry.alias( '=', 'is' ); +registry.register( + 'not is', + ( ...args: Parameters< typeof evaluateIs > ) => ! evaluateIs( ...args ) +); +registry.alias( '!=', 'not is' ); +registry.register( 'in', evaluateInclusion ); + +registry.register( + 'not in', + ( ...args: Parameters< typeof evaluateInclusion > ) => + ! evaluateInclusion( ...args ) +); +registry.alias( '!in', 'in' ); +registry.register( 'contains', ( source: Source, target: Target, rule: Rule ) => + // We need to reverse the source and target for the contains operator. + evaluateInclusion( target, source, rule ) +); +registry.register( + 'not contains', + ( source: Source, target: Target, rule: Rule ) => + // We need to reverse the source and target for the not contains operator. + ! evaluateInclusion( target, source, rule ) +); +registry.alias( '!contains', 'contains' ); +registry.register( 'less than', evaluateNumericCompare ); +registry.alias( '<', 'less than' ); +registry.register( + 'greater than', + ( source: Source, target: Target, rule: Rule ) => + // We need to reverse the source and target for the greater than operator. + evaluateNumericCompare( target, source, rule ) +); +registry.alias( '>', 'greater than' ); +registry.register( 'lte', ( source: Source, target: Target, rule: Rule ) => + evaluateNumericCompare( source, target, rule, true ) +); +registry.alias( '<=', 'lte' ); +registry.register( 'gte', ( source: Source, target: Target, rule: Rule ) => + // We need to reverse the source and target for the greater than operator. + evaluateNumericCompare( target, source, rule, true ) +); +registry.alias( '>=', 'gte' ); +export { registry }; diff --git a/packages/rule-parser/src/helpers.ts b/packages/rule-parser/src/helpers.ts new file mode 100644 index 0000000000000..f2e24a8484e7b --- /dev/null +++ b/packages/rule-parser/src/helpers.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import type { Value, RawRule, Rule, Rules, StrictRules } from './types'; +import { combinators } from './constants'; + +function isPrimitive( value: unknown ): value is Value { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ); +} + +function isPrimitiveArray( value: unknown ): value is Array< Value > { + if ( ! Array.isArray( value ) ) { + return false; + } + const type = typeof value[ 0 ]; + return value.every( ( item ) => typeof item === type ); +} + +export function valueType( value: Value | Value[] ): string { + return Array.isArray( value ) ? typeof value[ 0 ] : typeof value; +} + +export function isRawRule( rule: unknown ): rule is RawRule { + if ( ! Array.isArray( rule ) ) { + return false; + } + + if ( rule.length !== 3 ) { + return false; + } + + const [ source, operator, target ] = rule; + + if ( typeof source !== 'string' ) { + return false; + } + + if ( typeof operator !== 'string' ) { + return false; + } + + if ( ! isPrimitive( target ) && ! isPrimitiveArray( target ) ) { + return false; + } + + return true; +} + +export function isRule( rule: unknown ): rule is Rule { + if ( ! Array.isArray( rule ) ) { + return false; + } + + if ( rule.length !== 3 ) { + return false; + } + + const [ source, operator, target ] = rule; + + if ( ! isPrimitive( source ) && ! isPrimitiveArray( source ) ) { + return false; + } + + if ( typeof operator !== 'string' ) { + return false; + } + + if ( ! isPrimitive( target ) && ! isPrimitiveArray( target ) ) { + return false; + } + + return true; +} + +export function stringifyRule( rule: Rule ): string { + const [ source, operator, target ] = rule; + const sourceString = Array.isArray( source ) + ? `[ ${ source.join( ', ' ) } ]` + : source; + const targetString = Array.isArray( target ) + ? `[ ${ target.join( ', ' ) } ]` + : target; + + return `[ ${ sourceString } ${ operator } ${ targetString } ]`; +} + +export function isStructuredRule< T extends Rule | boolean >( + rules: Rules< T > +): rules is StrictRules< T > { + return ( + Array.isArray( rules ) && + typeof rules[ 0 ] === 'string' && + combinators.includes( rules[ 0 ] ) + ); +} diff --git a/packages/rule-parser/src/index.js b/packages/rule-parser/src/index.js new file mode 100644 index 0000000000000..7853029a9dcb4 --- /dev/null +++ b/packages/rule-parser/src/index.js @@ -0,0 +1,2 @@ +export { parser } from './parser'; +export { registry } from './evaluators'; diff --git a/packages/rule-parser/src/parser.ts b/packages/rule-parser/src/parser.ts new file mode 100644 index 0000000000000..a340b8b988458 --- /dev/null +++ b/packages/rule-parser/src/parser.ts @@ -0,0 +1,94 @@ +/** + * Internal dependencies + */ +import type { Rules, RawRule, Rule, Store } from './types'; +import { isStructuredRule, isRule, isRawRule } from './helpers'; +import { registry } from './evaluators'; + +function replaceSources( + rules: Rules< RawRule >, + store: Store +): Rules< Rule > { + if ( isStructuredRule< RawRule >( rules ) ) { + const [ combinator, subRules ] = rules; + return [ + combinator, + subRules.map( ( rule ) => { + if ( isRawRule( rule ) ) { + const [ source, operator, target ] = rule; + const resolvedSource = store[ source ]; + return [ resolvedSource, operator, target ] as Rule; + } + return replaceSources( rule, store ); + } ), + ]; + } + + return rules.map( ( rule ) => { + if ( isRawRule( rule ) ) { + const [ source, operator, target ] = rule; + const resolvedSource = store[ source ]; + return [ resolvedSource, operator, target ] as Rule; + } + return replaceSources( rule, store ); + } ); +} + +function evaluateRule( rule: Rule ): boolean { + const [ source, operator, target ] = rule; + + return registry.call( operator, source, target, rule ); +} + +function transformRules( rules: Rules< Rule > ): Rules< boolean > { + if ( isStructuredRule< Rule >( rules ) ) { + const [ combinator, subRules ] = rules; + return [ + combinator, + subRules.map( ( rule ) => { + if ( isRule( rule ) ) { + return evaluateRule( rule ); + } + return transformRules( rule ); + } ), + ]; + } + return rules.map( ( rule ) => { + if ( isRule( rule ) ) { + return evaluateRule( rule ); + } + return transformRules( rule ); + } ); +} + +function reduceRules( rules: Rules< boolean > ): boolean { + if ( isStructuredRule< boolean >( rules ) ) { + const [ combinator, subRules ] = rules; + if ( combinator === 'ALL' ) { + return subRules.every( ( rule ) => { + if ( Array.isArray( rule ) ) { + return reduceRules( rule ); + } + return rule; + } ); + } + return subRules.some( ( rule ) => { + if ( Array.isArray( rule ) ) { + return reduceRules( rule ); + } + return rule; + } ); + } + return rules.every( ( rule ) => { + if ( Array.isArray( rule ) ) { + return reduceRules( rule ); + } + return rule; + } ); +} + +export function parser( rules: Rules< RawRule >, store: Store ): boolean { + const structuredRules = replaceSources( rules, store ); + const transformedRules = transformRules( structuredRules ); + return reduceRules( transformedRules ); +} diff --git a/packages/rule-parser/src/test/index.ts b/packages/rule-parser/src/test/index.ts new file mode 100644 index 0000000000000..c5a85e6b3a05f --- /dev/null +++ b/packages/rule-parser/src/test/index.ts @@ -0,0 +1,185 @@ +/** + * Internal dependencies + */ +import type { Rules, RawRule, Store, Source, Target } from '../types'; +import { parser, registry } from '..'; + +const store: Store = { + 'cart.cartTotal': 75, + 'cart.cartItems': [ 1, 2, 3, 4, 5 ], + 'customer.id': 1, + 'customer.role': 'custom-role', +}; + +describe( 'Parser', () => { + it( 'should parse simple rules', () => { + const rules: Rules< RawRule > = [ + [ 'cart.cartTotal', 'less than', 100 ], + [ 'cart.cartTotal', 'greater than', 50 ], + [ 'cart.cartItems', 'contains', 5 ], + [ 'cart.cartItems', 'not contains', 6 ], + [ 'customer.id', 'in', [ 1, 2, 3 ] ], + [ 'customer.id', 'not in', [ 4, 5, 6 ] ], + [ 'customer.role', 'is', 'custom-role' ], + [ 'customer.role', 'not is', 'customer' ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse rule with ALL', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ + [ 'cart.cartTotal', 'less than', 100 ], + [ 'cart.cartTotal', 'greater than', 50 ], + [ 'cart.cartItems', 'contains', 5 ], + [ 'cart.cartItems', 'not contains', 6 ], + ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse rule with ANY', () => { + const rules: Rules< RawRule > = [ + 'ANY', + [ + [ 'cart.cartTotal', 'less than', 100 ], + [ 'customer.id', 'is', 3 ], + ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse nested rules', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ + [ 'cart.cartTotal', 'less than', 100 ], + [ 'cart.cartTotal', 'greater than', 50 ], + [ + 'ANY', + [ + [ 'cart.cartItems', 'contains', 5 ], + [ 'cart.cartItems', 'not contains', 6 ], + ], + ], + ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse rules with aliases', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ + [ 'cart.cartTotal', '<', 100 ], + [ 'cart.cartTotal', '>', 50 ], + [ 'cart.cartTotal', 'lte', 75 ], + [ 'cart.cartTotal', 'gte', 75 ], + [ 'customer.id', '=', 1 ], + [ 'customer.id', '!=', 2 ], + ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse rules that return false', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ + [ 'cart.cartTotal', 'less than', 100 ], + [ 'cart.cartTotal', 'greater than', 100 ], + ], + ]; + + expect( parser( rules, store ) ).toBe( false ); + } ); + + it( 'should parse rules that return false and ANY', () => { + const rules: Rules< RawRule > = [ + 'ANY', + [ + [ 'cart.cartTotal', 'greater than', 100 ], + [ 'cart.cartTotal', 'less than', 50 ], + ], + ]; + + expect( parser( rules, store ) ).toBe( false ); + } ); + + it( 'should parse rules with greater than or equal to', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ [ 'cart.cartTotal', 'gte', 75 ] ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + } ); + + it( 'should parse rules with floating numbers', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ [ 'cart.cartTotal', 'less than', 75.5 ] ], + ]; + + expect( parser( rules, { 'cart.cartTotal': '75.3' } ) ).toBe( true ); + } ); + + it( 'should not parse with nonexistent comparator', () => { + const rules: Rules< RawRule > = [ + 'ALL', + [ + [ 'cart.cartTotal', 'does not exist', 100 ], + [ 'cart.cartTotal', 'does not exist', 50 ], + ], + ]; + + const error = () => { + parser( rules, store ); + }; + + expect( error ).toThrow( + `No such evaluator with key "does not exist" exists.` + ); + } ); + + it( 'should parse with newly introduced evaluator', () => { + const betweenEvaluator = ( source: Source, target: Target ) => { + return source >= target[ 0 ] && source <= target[ 1 ]; + }; + + registry.register( 'between', betweenEvaluator ); + + const rules: Rules< RawRule > = [ + 'ALL', + [ [ 'cart.cartTotal', 'between', [ 50, 100 ] ] ], + ]; + + expect( parser( rules, store ) ).toBe( true ); + + const falseRules: Rules< RawRule > = [ + 'ALL', + [ [ 'cart.cartTotal', 'between', [ 100, 200 ] ] ], + ]; + + expect( parser( falseRules, store ) ).toBe( false ); + } ); + + it( 'should correctly evaluate contains operator with arrays', () => { + const rulesSourceArray: Rules< RawRule > = [ + 'ALL', + [ [ 'cart.items', 'contains', [ 'apple', 'orange' ] ] ], + ]; + + const storeSourceArray = { + 'cart.items': [ 'banana', 'apple', 'orange' ], + }; + + expect( parser( rulesSourceArray, storeSourceArray ) ).toBe( true ); + } ); +} ); diff --git a/packages/rule-parser/src/types.ts b/packages/rule-parser/src/types.ts new file mode 100644 index 0000000000000..e43f54221a60e --- /dev/null +++ b/packages/rule-parser/src/types.ts @@ -0,0 +1,33 @@ +export type Value = string | number | boolean; +export type Source = Value | Value[]; +export type Target = Value | Value[]; +export type Operator = string; +export type Combinator = 'ANY' | 'ALL'; +export type Store = Record< string, Source >; +/** + * A rule is a tuple of source, operator, and target. + * Source can either be raw (e.g. "cart.cartTotal") or a resolved value (e.g. 100). + */ +type PrimitiveRule< T > = [ T, Operator, Target ]; +export type RawRule = PrimitiveRule< string >; +export type Rule = PrimitiveRule< Source >; +/** + * A collection of rules can be an array of rules or a pair of combinator and an array of rules. Rules can be nested. Lack of initial combinator defaults to 'AND'. + */ +export type Rules< T extends RawRule | Rule | boolean > = + | [ Combinator, Array< T | Rules< T > > ] + | Array< T | Rules< T > >; +/** + * StrictRules are like Rules, but they are not allowed to be an array of Rules, they need to have a combinator. + */ +export type StrictRules< T extends RawRule | Rule | boolean > = Exclude< + Rules< T >, + Array< T | Rules< T > > +>; + +export type EvaluatorFunction = ( + source: Source, + target: Target, + rule: Rule, + ...args: any[] +) => boolean; diff --git a/packages/rule-parser/tsconfig.json b/packages/rule-parser/tsconfig.json new file mode 100644 index 0000000000000..6e33d8ff82d47 --- /dev/null +++ b/packages/rule-parser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "declarationDir": "build-types" + }, + "include": [ "src/**/*" ] +} diff --git a/tsconfig.json b/tsconfig.json index 3ab54f66019bc..b60289a70f627 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,6 +48,7 @@ { "path": "packages/redux-routine" }, { "path": "packages/report-flaky-tests" }, { "path": "packages/rich-text" }, + { "path": "packages/rule-parser" }, { "path": "packages/style-engine" }, { "path": "packages/sync" }, { "path": "packages/token-list" },